diff --git a/.github/workflows/dev_module_build.yml b/.github/workflows/dev_module_build.yml index 93088ddcc7..d36d41b681 100644 --- a/.github/workflows/dev_module_build.yml +++ b/.github/workflows/dev_module_build.yml @@ -332,6 +332,33 @@ jobs: task virtualization-controller:init task virtualization-controller:test:unit + cdi_artifact: + runs-on: ubuntu-22.04 + name: cdi-artifact lint and unit tests + steps: + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: "${{ env.GO_VERSION }}" + + - name: Install Task + uses: go-task/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v${{ env.GOLANGCI_LINT_VERSION}} + echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: Run cdi-artifact lint and unit tests + run: task cdi-artifact:ci + test_scripts_js: runs-on: ubuntu-22.04 name: Run JS unit tests diff --git a/Taskfile.yaml b/Taskfile.yaml index cc83d74680..7d94837f00 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -15,6 +15,9 @@ includes: gohooks: taskfile: ./images/hooks dir: ./images/hooks + cdi-artifact: + taskfile: ./images/cdi-artifact + dir: ./images/cdi-artifact e2e: taskfile: ./test/e2e dir: ./test/e2e diff --git a/api/client/kubeclient/client.go b/api/client/kubeclient/client.go index e0063001f0..a337dadd21 100644 --- a/api/client/kubeclient/client.go +++ b/api/client/kubeclient/client.go @@ -56,6 +56,7 @@ type Client interface { VirtualMachines(namespace string) virtualizationv1alpha2.VirtualMachineInterface VirtualImages(namespace string) virtualizationv1alpha2.VirtualImageInterface VirtualDisks(namespace string) virtualizationv1alpha2.VirtualDiskInterface + VirtualDiskSnapshots(namespace string) virtualizationv1alpha2.VirtualDiskSnapshotInterface VirtualMachineBlockDeviceAttachments(namespace string) virtualizationv1alpha2.VirtualMachineBlockDeviceAttachmentInterface VirtualMachineIPAddresses(namespace string) virtualizationv1alpha2.VirtualMachineIPAddressInterface VirtualMachineIPAddressLeases() virtualizationv1alpha2.VirtualMachineIPAddressLeaseInterface @@ -96,6 +97,10 @@ func (c client) VirtualDisks(namespace string) virtualizationv1alpha2.VirtualDis return c.virtClient.VirtualizationV1alpha2().VirtualDisks(namespace) } +func (c client) VirtualDiskSnapshots(namespace string) virtualizationv1alpha2.VirtualDiskSnapshotInterface { + return c.virtClient.VirtualizationV1alpha2().VirtualDiskSnapshots(namespace) +} + func (c client) VirtualMachineBlockDeviceAttachments(namespace string) virtualizationv1alpha2.VirtualMachineBlockDeviceAttachmentInterface { return c.virtClient.VirtualizationV1alpha2().VirtualMachineBlockDeviceAttachments(namespace) } diff --git a/build/components/versions.yml b/build/components/versions.yml index 80a01cdc1d..c73c45325b 100644 --- a/build/components/versions.yml +++ b/build/components/versions.yml @@ -3,8 +3,7 @@ firmware: libvirt: v10.9.0 edk2: stable202411 core: - 3p-kubevirt: v1.6.2-v12n.49 - 3p-containerized-data-importer: v1.60.3-v12n.20 + 3p-kubevirt: fix/volume-umount-retries distribution: 2.8.3 package: acl: v2.3.1 diff --git a/crds/embedded/datavolumes.yaml b/crds/embedded/datavolumes.yaml deleted file mode 100644 index 1d9eda8338..0000000000 --- a/crds/embedded/datavolumes.yaml +++ /dev/null @@ -1,748 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: internalvirtualizationdatavolumes.cdi.internal.virtualization.deckhouse.io - labels: - heritage: deckhouse - module: virtualization - app.kubernetes.io/component: cdi -spec: - conversion: - strategy: None - group: cdi.internal.virtualization.deckhouse.io - names: - categories: - - intvirt - kind: InternalVirtualizationDataVolume - listKind: InternalVirtualizationDataVolumeList - plural: internalvirtualizationdatavolumes - shortNames: - - intvirtdv - singular: internalvirtualizationdatavolume - scope: Namespaced - versions: - - additionalPrinterColumns: - - description: The phase the data volume is in - jsonPath: .status.phase - name: Phase - type: string - - description: Transfer progress in percentage if known, N/A otherwise - jsonPath: .status.progress - name: Progress - type: string - - description: The number of times the transfer has been restarted. - jsonPath: .status.restartCount - name: Restarts - type: integer - - jsonPath: .metadata.creationTimestamp - name: Age - type: date - name: v1beta1 - schema: - openAPIV3Schema: - description: DataVolume is an abstraction on top of PersistentVolumeClaims - to allow easy population of those PersistentVolumeClaims with relation to - VirtualMachines - 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: DataVolumeSpec defines the DataVolume type specification - properties: - checkpoints: - description: Checkpoints is a list of DataVolumeCheckpoints, representing - stages in a multistage import. - items: - description: DataVolumeCheckpoint defines a stage in a warm migration. - properties: - current: - description: Current is the identifier of the snapshot created - for this checkpoint. - type: string - previous: - description: Previous is the identifier of the snapshot from - the previous checkpoint. - type: string - required: - - current - - previous - type: object - type: array - contentType: - description: 'DataVolumeContentType options: "kubevirt", "archive"' - enum: - - kubevirt - - archive - type: string - finalCheckpoint: - description: FinalCheckpoint indicates whether the current DataVolumeCheckpoint - is the final checkpoint. - type: boolean - preallocation: - description: Preallocation controls whether storage for DataVolumes - should be allocated in advance. - type: boolean - priorityClassName: - description: PriorityClassName for Importer, Cloner and Uploader pod - type: string - pvc: - description: PVC is the PVC specification - properties: - accessModes: - description: 'accessModes contains the desired access modes the - volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1' - items: - type: string - type: array - dataSource: - description: 'dataSource field can be used to specify either: - * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) - * An existing PVC (PersistentVolumeClaim) If the provisioner - or an external controller can support the specified data source, - it will create a new volume based on the contents of the specified - data source. When the AnyVolumeDataSource feature gate is enabled, - dataSource contents will be copied to dataSourceRef, and dataSourceRef - contents will be copied to dataSource when dataSourceRef.namespace - is not specified. If the namespace is specified, then dataSourceRef - will not be copied to dataSource.' - properties: - apiGroup: - description: APIGroup is the group for the resource being - referenced. If APIGroup is not specified, the specified - Kind must be in the core API group. For any other third-party - types, APIGroup is required. - type: string - kind: - description: Kind is the type of resource being referenced - type: string - name: - description: Name is the name of resource being referenced - type: string - required: - - kind - - name - type: object - x-kubernetes-map-type: atomic - dataSourceRef: - description: 'dataSourceRef specifies the object from which to - populate the volume with data, if a non-empty volume is desired. - This may be any object from a non-empty API group (non core - object) or a PersistentVolumeClaim object. When this field is - specified, volume binding will only succeed if the type of the - specified object matches some installed volume populator or - dynamic provisioner. This field will replace the functionality - of the dataSource field and as such if both fields are non-empty, - they must have the same value. For backwards compatibility, - when namespace isn''t specified in dataSourceRef, both fields - (dataSource and dataSourceRef) will be set to the same value - automatically if one of them is empty and the other is non-empty. - When namespace is specified in dataSourceRef, dataSource isn''t - set to the same value and must be empty. There are three important - differences between dataSource and dataSourceRef: * While dataSource - only allows two specific types of objects, dataSourceRef allows - any non-core object, as well as PersistentVolumeClaim objects. - * While dataSource ignores disallowed values (dropping them), - dataSourceRef preserves all values, and generates an error if - a disallowed value is specified. * While dataSource only allows - local objects, dataSourceRef allows objects in any namespaces. - (Beta) Using this field requires the AnyVolumeDataSource feature - gate to be enabled. (Alpha) Using the namespace field of dataSourceRef - requires the CrossNamespaceVolumeDataSource feature gate to - be enabled.' - properties: - apiGroup: - description: APIGroup is the group for the resource being - referenced. If APIGroup is not specified, the specified - Kind must be in the core API group. For any other third-party - types, APIGroup is required. - type: string - kind: - description: Kind is the type of resource being referenced - type: string - name: - description: Name is the name of resource being referenced - type: string - namespace: - description: Namespace is the namespace of resource being - referenced Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant - object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. (Alpha) This field requires the - CrossNamespaceVolumeDataSource feature gate to be enabled. - type: string - required: - - kind - - name - type: object - resources: - description: 'resources represents the minimum resources the volume - should have. If RecoverVolumeExpansionFailure feature is enabled - users are allowed to specify resource requirements that are - lower than previous value but must still be higher than capacity - recorded in the status field of the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources' - properties: - claims: - description: "Claims lists the names of resources, defined - in spec.resourceClaims, that are used by this container. - \n This is an alpha field and requires enabling the DynamicResourceAllocation - feature gate. \n 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 - 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 - selector: - description: selector is a label query over volumes to consider - for binding. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: A label selector requirement is a selector - that contains values, a key, and an operator that relates - the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: operator represents a key's relationship - to a set of values. Valid operators are In, NotIn, - Exists and DoesNotExist. - type: string - values: - description: values is an array of string values. If - the operator is In or NotIn, the values array must - be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced - during a strategic merge patch. - items: - type: string - type: array - required: - - key - - operator - type: object - type: array - matchLabels: - additionalProperties: - type: string - description: matchLabels is a map of {key,value} pairs. A - single {key,value} in the matchLabels map is equivalent - to an element of matchExpressions, whose key field is "key", - the operator is "In", and the values array contains only - "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - storageClassName: - description: 'storageClassName is the name of the StorageClass - required by the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1' - type: string - volumeMode: - description: volumeMode defines what type of volume is required - by the claim. Value of Filesystem is implied when not included - in claim spec. - type: string - volumeName: - description: volumeName is the binding reference to the PersistentVolume - backing this claim. - type: string - type: object - source: - description: Source is the src of the data for the requested DataVolume - properties: - blank: - description: DataVolumeBlankImage provides the parameters to create - a new raw blank image for the PVC - type: object - gcs: - description: DataVolumeSourceGCS provides the parameters to create - a Data Volume from an GCS source - properties: - secretRef: - description: SecretRef provides the secret reference needed - to access the GCS source - type: string - url: - description: URL is the url of the GCS source - type: string - required: - - url - type: object - http: - description: DataVolumeSourceHTTP can be either an http or https - endpoint, with an optional basic auth user name and password, - and an optional configmap containing additional CAs - properties: - certConfigMap: - description: CertConfigMap is a configmap reference, containing - a Certificate Authority(CA) public key, and a base64 encoded - pem certificate - type: string - extraHeaders: - description: ExtraHeaders is a list of strings containing - extra headers to include with HTTP transfer requests - items: - type: string - type: array - secretExtraHeaders: - description: SecretExtraHeaders is a list of Secret references, - each containing an extra HTTP header that may include sensitive - information - items: - type: string - type: array - secretRef: - description: SecretRef A Secret reference, the secret should - contain accessKeyId (user name) base64 encoded, and secretKey - (password) also base64 encoded - type: string - url: - description: URL is the URL of the http(s) endpoint - type: string - required: - - url - type: object - imageio: - description: DataVolumeSourceImageIO provides the parameters to - create a Data Volume from an imageio source - properties: - certConfigMap: - description: CertConfigMap provides a reference to the CA - cert - type: string - diskId: - description: DiskID provides id of a disk to be imported - type: string - secretRef: - description: SecretRef provides the secret reference needed - to access the ovirt-engine - type: string - url: - description: URL is the URL of the ovirt-engine - type: string - required: - - diskId - - url - type: object - pvc: - description: DataVolumeSourcePVC provides the parameters to create - a Data Volume from an existing PVC - properties: - name: - description: The name of the source PVC - type: string - namespace: - description: The namespace of the source PVC - type: string - required: - - name - - namespace - type: object - registry: - description: DataVolumeSourceRegistry provides the parameters - to create a Data Volume from an registry source - properties: - certConfigMap: - description: CertConfigMap provides a reference to the Registry - certs - type: string - imageStream: - description: ImageStream is the name of image stream for import - type: string - pullMethod: - description: PullMethod can be either "pod" (default import), - or "node" (node docker cache based import) - type: string - secretRef: - description: SecretRef provides the secret reference needed - to access the Registry source - type: string - url: - description: 'URL is the url of the registry source (starting - with the scheme: docker, oci-archive)' - type: string - type: object - s3: - description: DataVolumeSourceS3 provides the parameters to create - a Data Volume from an S3 source - properties: - certConfigMap: - description: CertConfigMap is a configmap reference, containing - a Certificate Authority(CA) public key, and a base64 encoded - pem certificate - type: string - secretRef: - description: SecretRef provides the secret reference needed - to access the S3 source - type: string - url: - description: URL is the url of the S3 source - type: string - required: - - url - type: object - snapshot: - description: DataVolumeSourceSnapshot provides the parameters - to create a Data Volume from an existing VolumeSnapshot - properties: - name: - description: The name of the source VolumeSnapshot - type: string - namespace: - description: The namespace of the source VolumeSnapshot - type: string - required: - - name - - namespace - type: object - upload: - description: DataVolumeSourceUpload provides the parameters to - create a Data Volume by uploading the source - type: object - vddk: - description: DataVolumeSourceVDDK provides the parameters to create - a Data Volume from a Vmware source - properties: - backingFile: - description: BackingFile is the path to the virtual hard disk - to migrate from vCenter/ESXi - type: string - initImageURL: - description: InitImageURL is an optional URL to an image containing - an extracted VDDK library, overrides v2v-vmware config map - type: string - secretRef: - description: SecretRef provides a reference to a secret containing - the username and password needed to access the vCenter or - ESXi host - type: string - thumbprint: - description: Thumbprint is the certificate thumbprint of the - vCenter or ESXi host - type: string - url: - description: URL is the URL of the vCenter or ESXi host with - the VM to migrate - type: string - uuid: - description: UUID is the UUID of the virtual machine that - the backing file is attached to in vCenter/ESXi - type: string - type: object - type: object - sourceRef: - description: SourceRef is an indirect reference to the source of data - for the requested DataVolume - properties: - kind: - description: The kind of the source reference, currently only - "DataSource" is supported - type: string - name: - description: The name of the source reference - type: string - namespace: - description: The namespace of the source reference, defaults to - the DataVolume namespace - type: string - required: - - kind - - name - type: object - storage: - description: Storage is the requested storage specification - properties: - accessModes: - description: 'AccessModes contains the desired access modes the - volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1' - items: - type: string - type: array - dataSource: - description: 'This field can be used to specify either: * An existing - VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) - * An existing PVC (PersistentVolumeClaim) * An existing custom - resource that implements data population (Alpha) In order to - use custom resource types that implement data population, the - AnyVolumeDataSource feature gate must be enabled. If the provisioner - or an external controller can support the specified data source, - it will create a new volume based on the contents of the specified - data source. If the AnyVolumeDataSource feature gate is enabled, - this field will always have the same contents as the DataSourceRef - field.' - properties: - apiGroup: - description: APIGroup is the group for the resource being - referenced. If APIGroup is not specified, the specified - Kind must be in the core API group. For any other third-party - types, APIGroup is required. - type: string - kind: - description: Kind is the type of resource being referenced - type: string - name: - description: Name is the name of resource being referenced - type: string - required: - - kind - - name - type: object - x-kubernetes-map-type: atomic - dataSourceRef: - description: 'Specifies the object from which to populate the - volume with data, if a non-empty volume is desired. This may - be any local object from a non-empty API group (non core object) - or a PersistentVolumeClaim object. When this field is specified, - volume binding will only succeed if the type of the specified - object matches some installed volume populator or dynamic provisioner. - This field will replace the functionality of the DataSource - field and as such if both fields are non-empty, they must have - the same value. For backwards compatibility, both fields (DataSource - and DataSourceRef) will be set to the same value automatically - if one of them is empty and the other is non-empty. There are - two important differences between DataSource and DataSourceRef: - * While DataSource only allows two specific types of objects, - DataSourceRef allows any non-core object, as well as PersistentVolumeClaim - objects. * While DataSource ignores disallowed values (dropping - them), DataSourceRef preserves all values, and generates an - error if a disallowed value is specified. (Beta) Using this - field requires the AnyVolumeDataSource feature gate to be enabled.' - properties: - apiGroup: - description: APIGroup is the group for the resource being - referenced. If APIGroup is not specified, the specified - Kind must be in the core API group. For any other third-party - types, APIGroup is required. - type: string - kind: - description: Kind is the type of resource being referenced - type: string - name: - description: Name is the name of resource being referenced - type: string - namespace: - description: Namespace is the namespace of resource being - referenced Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant - object is required in the referent namespace to allow that - namespace's owner to accept the reference. See the ReferenceGrant - documentation for details. (Alpha) This field requires the - CrossNamespaceVolumeDataSource feature gate to be enabled. - type: string - required: - - kind - - name - type: object - resources: - description: 'Resources represents the minimum resources the volume - should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources' - properties: - claims: - description: "Claims lists the names of resources, defined - in spec.resourceClaims, that are used by this container. - \n This is an alpha field and requires enabling the DynamicResourceAllocation - feature gate. \n 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 - 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 - selector: - description: A label query over volumes to consider for binding. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: A label selector requirement is a selector - that contains values, a key, and an operator that relates - the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: operator represents a key's relationship - to a set of values. Valid operators are In, NotIn, - Exists and DoesNotExist. - type: string - values: - description: values is an array of string values. If - the operator is In or NotIn, the values array must - be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced - during a strategic merge patch. - items: - type: string - type: array - required: - - key - - operator - type: object - type: array - matchLabels: - additionalProperties: - type: string - description: matchLabels is a map of {key,value} pairs. A - single {key,value} in the matchLabels map is equivalent - to an element of matchExpressions, whose key field is "key", - the operator is "In", and the values array contains only - "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - storageClassName: - description: 'Name of the StorageClass required by the claim. - More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1' - type: string - volumeMode: - description: volumeMode defines what type of volume is required - by the claim. Value of Filesystem is implied when not included - in claim spec. - type: string - volumeName: - description: VolumeName is the binding reference to the PersistentVolume - backing this claim. - type: string - type: object - type: object - status: - description: DataVolumeStatus contains the current status of the DataVolume - properties: - claimName: - description: ClaimName is the name of the underlying PVC used by the - DataVolume. - type: string - conditions: - items: - description: DataVolumeCondition represents the state of a data - volume condition. - properties: - lastHeartbeatTime: - format: date-time - type: string - lastTransitionTime: - format: date-time - type: string - message: - type: string - reason: - type: string - status: - type: string - type: - description: DataVolumeConditionType is the string representation - of known condition types - type: string - required: - - status - - type - type: object - type: array - phase: - description: Phase is the current phase of the data volume - type: string - progress: - description: DataVolumeProgress is the current progress of the DataVolume - transfer operation. Value between 0 and 100 inclusive, N/A if not - available - type: string - restartCount: - description: RestartCount is the number of times the pod populating - the DataVolume has restarted - format: int32 - type: integer - type: object - required: - - spec - type: object - served: true - storage: true - subresources: - status: {} diff --git a/crds/embedded/storageprofiles.yaml b/crds/embedded/storageprofiles.yaml new file mode 100644 index 0000000000..7adce9286e --- /dev/null +++ b/crds/embedded/storageprofiles.yaml @@ -0,0 +1,155 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + labels: + heritage: deckhouse + module: virtualization + app.kubernetes.io/component: virtualization-controller + name: internalvirtualizationstorageprofiles.cdi.internal.virtualization.deckhouse.io +spec: + group: cdi.internal.virtualization.deckhouse.io + names: + categories: + - intvirt + kind: InternalVirtualizationStorageProfile + listKind: InternalVirtualizationStorageProfileList + plural: internalvirtualizationstorageprofiles + shortNames: + - intvirtsp + singular: internalvirtualizationstorageprofile + scope: Cluster + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: StorageProfile provides storage capability recommendations + 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: StorageProfileSpec defines specification for StorageProfile + properties: + claimPropertySets: + description: ClaimPropertySets is a provided set of properties applicable + to PVC + items: + description: ClaimPropertySet is a set of properties applicable + to PVC + properties: + accessModes: + description: |- + AccessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + maxItems: 4 + type: array + x-kubernetes-validations: + - message: Illegal AccessMode + rule: self.all(am, am in ['ReadWriteOnce', 'ReadOnlyMany', + 'ReadWriteMany', 'ReadWriteOncePod']) + volumeMode: + description: |- + VolumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + enum: + - Block + - Filesystem + type: string + required: + - accessModes + - volumeMode + type: object + maxItems: 8 + type: array + cloneStrategy: + description: CloneStrategy defines the preferred method for cloning + a volume + type: string + dataImportCronSourceFormat: + description: DataImportCronSourceFormat defines the format of the + DataImportCron-created disk image sources + type: string + snapshotClass: + description: SnapshotClass is optional specific VolumeSnapshotClass + for CloneStrategySnapshot. If not set, a VolumeSnapshotClass is + chosen according to the provisioner. + type: string + type: object + status: + description: StorageProfileStatus provides the most recently observed + status of the StorageProfile + properties: + claimPropertySets: + description: ClaimPropertySets computed from the spec and detected + in the system + items: + description: ClaimPropertySet is a set of properties applicable + to PVC + properties: + accessModes: + description: |- + AccessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + maxItems: 4 + type: array + x-kubernetes-validations: + - message: Illegal AccessMode + rule: self.all(am, am in ['ReadWriteOnce', 'ReadOnlyMany', + 'ReadWriteMany', 'ReadWriteOncePod']) + volumeMode: + description: |- + VolumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + enum: + - Block + - Filesystem + type: string + required: + - accessModes + - volumeMode + type: object + maxItems: 8 + type: array + cloneStrategy: + description: CloneStrategy defines the preferred method for cloning + a volume + type: string + dataImportCronSourceFormat: + description: DataImportCronSourceFormat defines the format of the + DataImportCron-created disk image sources + type: string + provisioner: + description: The Storage class provisioner plugin name + type: string + snapshotClass: + description: SnapshotClass is optional specific VolumeSnapshotClass + for CloneStrategySnapshot. If not set, a VolumeSnapshotClass is + chosen according to the provisioner. + type: string + storageClass: + description: The StorageClass name for which capabilities are defined + type: string + type: object + required: + - spec + type: object + served: true + storage: true diff --git a/crds/embedded/virtualmachines.yaml b/crds/embedded/virtualmachines.yaml index 52e6c381b8..45622d1a0c 100644 --- a/crds/embedded/virtualmachines.yaml +++ b/crds/embedded/virtualmachines.yaml @@ -4461,7 +4461,7 @@ spec: ready type: boolean restoreInProgress: - description: RestoreInProgress is the name of the VirtualMachineRestore + description: RestoreInProgress is the name of the restore operation currently executing type: string runStrategy: diff --git a/images/README.md b/images/README.md index ff6e759ef7..9ce6133595 100644 --- a/images/README.md +++ b/images/README.md @@ -4,4 +4,5 @@ Kubevirt is built as a single bundle as a virt-artifact. Then all necessary virt https://github.com/kubevirt/kubevirt/blob/v1.3.1/BUILD.bazel#L215-L224 -The same thing for cdi (cdi-artifact). +The CDI importer code used by virtual-disk-importer is vendored in +images/cdi-artifact and built from this repository. diff --git a/images/cdi-apiserver/mount-points.yaml b/images/cdi-apiserver/mount-points.yaml deleted file mode 100644 index 7f9f0c920b..0000000000 --- a/images/cdi-apiserver/mount-points.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# A list of pre-created mount points for containerd strict mode. - -dirs: - # Create dirs in /run, as /var/run is a symlink to /run. - - /run/certs/cdi-apiserver-signer-bundle - - /run/certs/cdi-apiserver-server-cert - - /kubeconfig.local diff --git a/images/cdi-apiserver/werf.inc.yaml b/images/cdi-apiserver/werf.inc.yaml deleted file mode 100644 index a005cef951..0000000000 --- a/images/cdi-apiserver/werf.inc.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -image: {{ .ModuleNamePrefix }}{{ .ImageName }} -fromImage: {{ .ModuleNamePrefix }}distroless -git: - {{- include "image mount points" . }} -import: -- image: {{ .ModuleNamePrefix }}cdi-artifact - add: /cdi-binaries - to: /usr/bin - before: setup - includePaths: - - cdi-apiserver -imageSpec: - config: - entrypoint: ["/usr/bin/cdi-apiserver", "-alsologtostderr"] - user: 64535 diff --git a/images/cdi-artifact/.gitignore b/images/cdi-artifact/.gitignore deleted file mode 100644 index c589aa74d6..0000000000 --- a/images/cdi-artifact/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Ignore workdirs with CDI sources. -__containerized-data-importer_* diff --git a/images/cdi-artifact/.golangci.yaml b/images/cdi-artifact/.golangci.yaml new file mode 100644 index 0000000000..47f1e7a405 --- /dev/null +++ b/images/cdi-artifact/.golangci.yaml @@ -0,0 +1,104 @@ +# https://golangci-lint.run/usage/configuration/ +version: "2" + +run: + concurrency: 4 + timeout: 10m + +issues: + # Show all errors. + max-issues-per-linter: 0 + max-same-issues: 0 + exclude: + - "don't use an underscore in package name" + +output: + sort-results: true + +exclusions: + paths: + - "^staging/.*" + - "^zz_generated.*" + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(kubevirt.io/containerized-data-importer/) + gofumpt: + extra-rules: true + goimports: + local-prefixes: kubevirt.io/containerized-data-importer/ + +linters: + default: none + enable: + - asciicheck + - bidichk + - bodyclose + - contextcheck + - copyloopvar + - dogsled + - errcheck + - errname + - errorlint + - gocritic + - govet + - ineffassign + - misspell + - nolintlint + - reassign + - revive + - staticcheck + - testifylint + - unconvert + - unparam + - unused + - usetesting + - testableexamples + - thelper + - tparallel + - whitespace + - wastedassign + - importas + settings: + errcheck: + exclude-functions: + - "(*os.File).Close" + - "(*net.TCPConn).Close" + - "(io.ReadCloser).Close" + - "(net.Listener).Close" + - "(net.Conn).Close" + - "(*golang.org/x/crypto/ssh.Session).Close" + - "(*github.com/fsnotify/fsnotify.Watcher).Close" + staticcheck: + dot-import-whitelist: + - github.com/onsi/ginkgo/v2 + - github.com/onsi/gomega + revive: + rules: + - name: dot-imports + disabled: true + - name: exported + disabled: true + - name: package-comments + disabled: true + nolintlint: + allow-no-explanation: [funlen, gocognit, lll] + require-explanation: true + require-specific: true + importas: + no-unaliased: true + no-extra-aliases: false + alias: + - pkg: k8s.io/api/core/v1 + alias: corev1 + - pkg: k8s.io/apimachinery/pkg/api/resource + alias: "" diff --git a/images/cdi-artifact/LICENSE b/images/cdi-artifact/LICENSE new file mode 100644 index 0000000000..549d874d4f --- /dev/null +++ b/images/cdi-artifact/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 The KubeVirt Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/images/cdi-artifact/README.md b/images/cdi-artifact/README.md new file mode 100644 index 0000000000..4d2d85827e --- /dev/null +++ b/images/cdi-artifact/README.md @@ -0,0 +1,13 @@ +# Virtualization CDI Importer Runtime + +This directory contains the reduced importer runtime used by the Deckhouse virtualization module. +It is based on the KubeVirt Containerized Data Importer codebase and keeps the original Apache 2.0 licensing. + +Only the runtime code needed by virtualization importer pods is kept here: + +- container image unpack/import into a PVC (DVCR path uses qemu-img); +- host-assigned PVC-to-PVC clone uses nbdcopy for byte-for-byte copy over NBD; +- raw/qcow2 conversion, resize, and progress reporting; +- nbdkit helpers needed by that import path. + +CDI controllers, APIs, DataVolume logic, upload proxy/server, VDDK, imageio, S3, GCS, and upstream development tooling are intentionally removed. diff --git a/images/cdi-artifact/Taskfile.yaml b/images/cdi-artifact/Taskfile.yaml index 7c840eaa04..33bb088a8b 100644 --- a/images/cdi-artifact/Taskfile.yaml +++ b/images/cdi-artifact/Taskfile.yaml @@ -2,59 +2,42 @@ version: "3" silent: true -env: - CDI_REPO: "https://github.com/kubevirt/containerized-data-importer.git" - CDI_VERSION: "v1.60.3" - tasks: - default: + test:unit: + desc: "Run go unit tests" cmds: - - task: status + - go test ./cmd/... ./pkg/... - status: - desc: "Show git status in cloned repo" + lint: + desc: "Run linters locally" cmds: - - | - dir=$(find . -type d -name __containerized-data-importer_\* -depth 1 | head -n1) - - if [[ -z $dir ]] ; then - echo "CDI not cloned, run 'task patch:new' to start working on a new patch." - exit 0 - fi + - task: lint:go - cd $dir - git status - - cleanup: - desc: "Remove cloned CDI git repo" + lint:go: + desc: "Run golangci-lint" + deps: + - _ensure:golangci-lint cmds: - - | - CDI_PATH=$(find . -type d -name __containerized-data-importer_\* -depth 1) - read -p "Delete these directories? [y/N] " answer + - golangci-lint run - if [[ "${answer}" != "${answer#[Yy]}" ]] ; then - rm -rf ${CDI_PATH} - fi - - patch:new: - desc: "Checkout CDI sources, create temp branch, apply all patches as individual commits" - cmds: - - | - ../../hack/patch_helper.sh --repo ${CDI_REPO} \ - --ref ${CDI_VERSION} \ - --patches-dir ./patches - - patch:edit: - desc: "Checkout CDI sources, create temp branch, apply patches with commits, leave specified patch uncommitted" + _ensure:golangci-lint: + desc: "Ensure golangci-lint v2+ is available" + internal: true cmds: - | - patchName="{{.CLI_ARGS}}" - if [[ -z ${patchName} ]] ; then - echo "Specify patch name, e.g.: task patch:edit -- 001-feature-name.patch" - exit 1 + GOLANGCI_LINT_MIN_VERSION="2.0" + if ! command -v golangci-lint &> /dev/null; then + echo "Installing golangci-lint..." + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin + fi + CURRENT_VERSION=$(golangci-lint --version 2>/dev/null | grep -oP 'version \K[0-9]+\.[0-9]+' || echo "0.0") + if [ "$(printf '%s\n' "$GOLANGCI_LINT_MIN_VERSION" "$CURRENT_VERSION" | sort -V | head -n1)" != "$GOLANGCI_LINT_MIN_VERSION" ]; then + echo "Upgrading golangci-lint from $CURRENT_VERSION to $GOLANGCI_LINT_MIN_VERSION+" + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin fi - ../../hack/patch_helper.sh --repo ${CDI_REPO} \ - --ref ${CDI_VERSION} \ - --patches-dir ./patches \ - --stop-at ${patchName} + ci: + desc: "Run lint and unit tests (CI entrypoint)" + cmds: + - task: lint + - task: test:unit diff --git a/images/cdi-artifact/cmd/pvc-importer/importer.go b/images/cdi-artifact/cmd/pvc-importer/importer.go new file mode 100644 index 0000000000..fec5582677 --- /dev/null +++ b/images/cdi-artifact/cmd/pvc-importer/importer.go @@ -0,0 +1,221 @@ +package main + +// importer.go imports a registry image into a target PVC. +// This process expects several environmental variables: +// ImporterEndpoint Source registry image URL. +// ImporterAccessKeyID Optional. Access key is the user ID that uniquely identifies your +// account. +// ImporterSecretKey Optional. Secret key is the password to your account. + +import ( + "errors" + "flag" + "fmt" + "os" + "strconv" + + corev1 "k8s.io/api/core/v1" + "k8s.io/klog/v2" + "k8s.io/utils/ptr" + + "kubevirt.io/containerized-data-importer/pkg/common" + "kubevirt.io/containerized-data-importer/pkg/importer" + "kubevirt.io/containerized-data-importer/pkg/util" + prometheusutil "kubevirt.io/containerized-data-importer/pkg/util/prometheus" +) + +const ( + completeMessage = "Import Complete" + + sourceRegistry = "registry" + + contentTypeKubeVirt = "kubevirt" + contentTypeArchive = "archive" +) + +func init() { + klog.InitFlags(nil) + flag.Parse() +} + +func touchDoneFile() { + doneFile, _ := util.ParseEnvVar(common.ImporterDoneFile, false) + if doneFile == "" { + return + } + f, err := os.OpenFile(doneFile, os.O_CREATE|os.O_EXCL, 0o666) + if err != nil { + klog.Errorf("Failed creating file %s: %+v", doneFile, err) + } + f.Close() +} + +func main() { + os.Exit(run()) +} + +func run() int { + defer klog.Flush() + + certsDirectory, err := os.MkdirTemp("", "certsdir") + if err != nil { + panic(err) + } + defer func() { _ = os.RemoveAll(certsDirectory) }() + prometheusutil.StartPrometheusEndpoint(certsDirectory) + klog.V(1).Infoln("Starting importer") + + source, _ := util.ParseEnvVar(common.ImporterSource, false) + contentType, _ := util.ParseEnvVar(common.ImporterContentType, false) + imageSize, _ := util.ParseEnvVar(common.ImporterImageSize, false) + filesystemOverhead, _ := strconv.ParseFloat(os.Getenv(common.FilesystemOverheadVar), 64) + preallocation := false + + volumeMode := corev1.PersistentVolumeBlock + if _, err := os.Stat(common.WriteBlockPath); os.IsNotExist(err) { + volumeMode = corev1.PersistentVolumeFilesystem + } + + // With writeback cache mode it's possible that the process will exit before all writes have been committed to storage. + // To guarantee that our write was committed to storage, we make a fsync syscall and ensure success. + // Also might be a good idea to sync any chmod's we might have done. + + // Registry import currently support kubevirt content type only + if contentType != contentTypeKubeVirt && source == sourceRegistry { + klog.Errorf("Unsupported content type %s when importing from %s", contentType, source) + return 1 + } + + if _, err := util.GetAvailableSpaceByVolumeMode(volumeMode); err != nil { + klog.Errorf("%+v", err) + return 1 + } + + exitCode := handleImport(source, contentType, volumeMode, imageSize, filesystemOverhead, preallocation) + if exitCode == scratchExitCode { + return 0 + } + if exitCode != 0 { + return exitCode + } + + fsyncDataFile(contentType, volumeMode) + return 0 +} + +const scratchExitCode = 2 + +func handleImport( + source string, + contentType string, + volumeMode corev1.PersistentVolumeMode, + imageSize string, + filesystemOverhead float64, + preallocation bool, +) int { + klog.V(1).Infoln("begin import process") + + ds := newDataSource(source) + defer func() { _ = ds.Close() }() + + processor := newDataProcessor(contentType, volumeMode, ds, imageSize, filesystemOverhead, preallocation) + err := processor.ProcessData() + + scratchSpaceRequired := errors.Is(err, importer.ErrRequiresScratchSpace) + if err != nil && !scratchSpaceRequired { + klog.Errorf("%+v", err) + if err := util.WriteTerminationMessage(fmt.Sprintf("Unable to process data: %v", err.Error())); err != nil { + klog.Errorf("%+v", err) + } + return 1 + } + + termMsg := ds.GetTerminationMessage() + if termMsg == nil { + termMsg = &common.TerminationMessage{} + } + termMsg.ScratchSpaceRequired = &scratchSpaceRequired + termMsg.PreallocationApplied = ptr.To(processor.PreallocationApplied()) + termMsg.Message = ptr.To(completeMessage) + + touchDoneFile() + if err := writeTerminationMessage(termMsg); err != nil { + klog.Errorf("%+v", err) + return 1 + } + + if scratchSpaceRequired { + return scratchExitCode + } + + return 0 +} + +func writeTerminationMessage(termMsg *common.TerminationMessage) error { + msg, err := termMsg.String() + if err != nil { + return err + } + if err := util.WriteTerminationMessage(msg); err != nil { + return err + } + klog.V(1).Infoln(msg) + return nil +} + +func newDataProcessor(contentType string, volumeMode corev1.PersistentVolumeMode, ds importer.DataSourceInterface, imageSize string, filesystemOverhead float64, preallocation bool) *importer.DataProcessor { + dest := getImporterDestPath(contentType, volumeMode) + processor := importer.NewDataProcessor(ds, dest, common.ImporterDataDir, common.ScratchDataDir, imageSize, filesystemOverhead, preallocation, os.Getenv(common.CacheMode)) + return processor +} + +func getImporterDestPath(contentType string, volumeMode corev1.PersistentVolumeMode) string { + dest := common.ImporterWritePath + + if contentType == contentTypeArchive { + dest = common.ImporterVolumePath + } + if volumeMode == corev1.PersistentVolumeBlock { + dest = common.WriteBlockPath + } + + return dest +} + +func newDataSource(source string) importer.DataSourceInterface { + ep, _ := util.ParseEnvVar(common.ImporterEndpoint, false) + acc, _ := util.ParseEnvVar(common.ImporterAccessKeyID, false) + sec, _ := util.ParseEnvVar(common.ImporterSecretKey, false) + certDir, _ := util.ParseEnvVar(common.ImporterCertDirVar, false) + insecureTLS, _ := strconv.ParseBool(os.Getenv(common.InsecureTLSVar)) + + switch source { + case sourceRegistry: + ds := importer.NewRegistryDataSource(ep, acc, sec, certDir, insecureTLS) + return ds + default: + klog.Errorf("Unknown source type %s\n", source) + err := util.WriteTerminationMessage(fmt.Sprintf("Unknown data source: %s", source)) + if err != nil { + klog.Errorf("%+v", err) + } + os.Exit(1) + } + + return nil +} + +func fsyncDataFile(contentType string, volumeMode corev1.PersistentVolumeMode) { + dataFile := getImporterDestPath(contentType, volumeMode) + file, err := os.Open(dataFile) + if err != nil { + klog.Errorf("could not get file descriptor for fsync call: %+v", err) + os.Exit(1) + } + if err := file.Sync(); err != nil { + klog.Errorf("could not fsync following qemu-img writing: %+v", err) + os.Exit(1) + } + klog.V(3).Infof("Successfully completed fsync(%s) syscall, committed to disk\n", dataFile) + file.Close() +} diff --git a/images/cdi-artifact/cmd/pvc-target-importer/importer.go b/images/cdi-artifact/cmd/pvc-target-importer/importer.go new file mode 100644 index 0000000000..a2db70742c --- /dev/null +++ b/images/cdi-artifact/cmd/pvc-target-importer/importer.go @@ -0,0 +1,112 @@ +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" + "kubevirt.io/containerized-data-importer/pkg/importer" + "kubevirt.io/containerized-data-importer/pkg/util" + prometheusutil "kubevirt.io/containerized-data-importer/pkg/util/prometheus" +) + +const ( + completeMessage = "Import Complete" + + nbdConnectTimeout = 10 * time.Minute +) + +func init() { + klog.InitFlags(nil) + flag.Parse() +} + +func main() { + os.Exit(run()) +} + +func run() int { + defer klog.Flush() + + certsDirectory, err := os.MkdirTemp("", "certsdir") + if err != nil { + panic(err) + } + defer func() { _ = os.RemoveAll(certsDirectory) }() + prometheusutil.StartPrometheusEndpoint(certsDirectory) + + nbdEndpoint, err := util.ParseEnvVar(common.ImporterNBDEndpoint, false) + if err != nil { + klog.Error(err) + return 1 + } + if nbdEndpoint == "" { + klog.Error("IMPORTER_NBD_ENDPOINT is required") + return 1 + } + + dest := importerDestPath() + if err := importer.WaitForNBDEndpoint(nbdEndpoint, nbdConnectTimeout); err != nil { + klog.Errorf("%+v", err) + if writeErr := util.WriteTerminationMessage(fmt.Sprintf("Unable to connect to NBD source: %v", err.Error())); writeErr != nil { + klog.Errorf("%+v", writeErr) + } + return 1 + } + + if err := importer.CopyNBDToDevice(nbdEndpoint, dest); err != nil { + klog.Errorf("%+v", err) + if writeErr := util.WriteTerminationMessage(fmt.Sprintf("Unable to copy NBD image: %v", err.Error())); writeErr != nil { + klog.Errorf("%+v", writeErr) + } + return 1 + } + + if err := fsyncDataFile(dest); err != nil { + klog.Errorf("%+v", err) + return 1 + } + if err := writeTerminationMessage(completeMessage); err != nil { + klog.Errorf("%+v", err) + return 1 + } + return 0 +} + +func importerDestPath() string { + if _, err := os.Stat(common.WriteBlockPath); err == nil { + return common.WriteBlockPath + } + return common.ImporterWritePath +} + +func writeTerminationMessage(message string) error { + termMsg := &common.TerminationMessage{Message: &message} + msg, err := termMsg.String() + if err != nil { + return err + } + if err := util.WriteTerminationMessage(msg); err != nil { + return err + } + klog.V(1).Infoln(msg) + return nil +} + +func fsyncDataFile(path string) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open file for fsync: %w", err) + } + defer file.Close() + + if err := file.Sync(); err != nil { + return fmt.Errorf("fsync after nbdcopy write: %w", err) + } + klog.V(3).Infof("Successfully completed fsync(%s) syscall, committed to disk\n", path) + return nil +} diff --git a/images/cdi-artifact/go.mod b/images/cdi-artifact/go.mod new file mode 100644 index 0000000000..e2dff67e4a --- /dev/null +++ b/images/cdi-artifact/go.mod @@ -0,0 +1,117 @@ +module kubevirt.io/containerized-data-importer + +go 1.24.0 + +toolchain go1.24.8 + +require ( + github.com/containers/image/v5 v5.32.0 + github.com/docker/go-units v0.5.0 + github.com/klauspost/compress v1.17.9 + github.com/machadovilaca/operator-observability v0.0.20 + github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.19.0 + github.com/prometheus/client_model v0.6.0 + github.com/ulikunitz/xz v0.5.15 + golang.org/x/sys v0.39.0 + k8s.io/api v0.30.2 + k8s.io/apimachinery v0.30.2 + k8s.io/client-go v8.0.0+incompatible + k8s.io/klog/v2 v2.120.1 + k8s.io/utils v0.0.0-20230726121419-3b25d923346b +) + +require ( + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect + github.com/containers/ocicrypt v1.2.0 // indirect + github.com/containers/storage v1.55.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.8.2 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/moby/sys/mountinfo v0.7.2 // indirect + github.com/moby/sys/user v0.2.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/onsi/ginkgo/v2 v2.17.1 // indirect + github.com/onsi/gomega v1.33.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/runtime-spec v1.2.0 // indirect + github.com/prometheus/common v0.51.1 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect + github.com/vbatts/tar-split v0.11.5 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) + +replace ( + github.com/chzyer/logex => github.com/chzyer/logex v1.2.1 + github.com/openshift/api => github.com/openshift/api v0.0.0-20240625084701-0689f006bcde + github.com/openshift/client-go => github.com/openshift/client-go v0.0.0-20240528061634-b054aa794d87 + github.com/openshift/library-go => github.com/openshift/library-go v0.0.0-20240621150525-4bb4238aef81 + github.com/operator-framework/operator-lifecycle-manager => github.com/operator-framework/operator-lifecycle-manager v0.0.0-20190128024246-5eb7ae5bdb7a + + k8s.io/api => k8s.io/api v0.30.2 + k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.30.2 + k8s.io/apimachinery => k8s.io/apimachinery v0.30.2 + k8s.io/apiserver => k8s.io/apiserver v0.30.2 + k8s.io/cli-runtime => k8s.io/cli-runtime v0.30.2 + k8s.io/client-go => k8s.io/client-go v0.30.2 + k8s.io/cloud-provider => k8s.io/cloud-provider v0.30.2 + k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.30.2 + k8s.io/code-generator => k8s.io/code-generator v0.30.2 + k8s.io/component-base => k8s.io/component-base v0.30.2 + k8s.io/component-helpers => k8s.io/component-helpers v0.30.2 + k8s.io/controller-manager => k8s.io/controller-manager v0.30.2 + k8s.io/cri-api => k8s.io/cri-api v0.30.2 + k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.30.2 + k8s.io/dynamic-resource-allocation => dynamic-resource-allocation v0.30.2 + k8s.io/endpointslice => k8s.io/endpointslice v0.30.2 + k8s.io/kms => k8s.io/kms v0.30.2 + k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.30.2 + k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.30.2 + k8s.io/kube-proxy => k8s.io/kube-proxy v0.30.2 + k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.30.2 + k8s.io/kubectl => k8s.io/kubectl v0.30.2 + k8s.io/kubelet => k8s.io/kubelet v0.30.2 + k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.30.2 + k8s.io/metrics => k8s.io/metrics v0.30.2 + k8s.io/mount-utils => k8s.io/mount-utils v0.30.2 + k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.30.2 + k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.30.2 + k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.30.2 + k8s.io/sample-controller => k8s.io/sample-controller v0.30.2 + + kubevirt.io/controller-lifecycle-operator-sdk/api => kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 + sigs.k8s.io/controller-runtime => sigs.k8s.io/controller-runtime v0.18.4 +) + +replace golang.org/x/crypto => golang.org/x/crypto v0.45.0 // CVE-2024-45337,CVE-2025-22869,CVE-2025-47914 + +// Use reduced github.com/docker/docker module: only compare is needed for CDI. +replace github.com/docker/docker => ./staging/src/github.com/docker/docker // CVE-2026-34040,CVE-2026-33997,CVE-2026-41567,CVE-2026-42306,CVE-2026-41568 diff --git a/images/cdi-artifact/go.sum b/images/cdi-artifact/go.sum new file mode 100644 index 0000000000..d22dfa1081 --- /dev/null +++ b/images/cdi-artifact/go.sum @@ -0,0 +1,258 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containers/image/v5 v5.32.0 h1:yjbweazPfr8xOzQ2hkkYm1A2V0jN96/kES6Gwyxj7hQ= +github.com/containers/image/v5 v5.32.0/go.mod h1:x5e0RDfGaY6bnQ13gJ2LqbfHvzssfB/y5a8HduGFxJc= +github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 h1:Qzk5C6cYglewc+UyGf6lc8Mj2UaPTHy/iF2De0/77CA= +github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY= +github.com/containers/ocicrypt v1.2.0 h1:X14EgRK3xNFvJEfI5O4Qn4T3E25ANudSOZz/sirVuPM= +github.com/containers/ocicrypt v1.2.0/go.mod h1:ZNviigQajtdlxIZGibvblVuIFBKIuUI2M0QM12SD31U= +github.com/containers/storage v1.55.0 h1:wTWZ3YpcQf1F+dSP4KxG9iqDfpQY1otaUXjPpffuhgg= +github.com/containers/storage v1.55.0/go.mod h1:28cB81IDk+y7ok60Of6u52RbCeBRucbFOeLunhER1RQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE= +github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 h1:hR7/MlvK23p6+lIw9SN1TigNLn9ZnF3W4SYRKq2gAHs= +github.com/google/pprof v0.0.0-20230602150820-91b7bce49751/go.mod h1:Jh3hGz2jkYak8qXPD19ryItVnUgpgeqzdkY/D0EaeuA= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/machadovilaca/operator-observability v0.0.20 h1:I9CLKWcaJU9KtPREhUu4yn/CLAZUpxFqEUz/ZVenkAI= +github.com/machadovilaca/operator-observability v0.0.20/go.mod h1:e4Z3VhOXb9InkmSh00JjqBBijE+iD+YMzynBpKB3+gE= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/user v0.2.0 h1:OnpapJsRp25vkhw8TFG6OLJODNh/3rEwRWtJ3kakwRM= +github.com/moby/sys/user v0.2.0/go.mod h1:RYstrcWOJpVh+6qzUqp2bU3eaRpdiQeKGlKitaH0PM8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= +github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= +github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= +github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= +github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= +github.com/prometheus/common v0.51.1 h1:eIjN50Bwglz6a/c3hAgSMcofL3nD+nFQkV6Dd4DsQCw= +github.com/prometheus/common v0.51.1/go.mod h1:lrWtQx+iDfn2mbH5GUzlH9TSHyfZpHkSiG1W7y3sF2Q= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts= +github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= +golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw= +golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.30.2 h1:+ZhRj+28QT4UOH+BKznu4CBgPWgkXO7XAvMcMl0qKvI= +k8s.io/api v0.30.2/go.mod h1:ULg5g9JvOev2dG0u2hig4Z7tQ2hHIuS+m8MNZ+X6EmI= +k8s.io/apimachinery v0.30.2 h1:fEMcnBj6qkzzPGSVsAZtQThU62SmQ4ZymlXRC5yFSCg= +k8s.io/apimachinery v0.30.2/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/client-go v0.30.2 h1:sBIVJdojUNPDU/jObC+18tXWcTJVcwyqS9diGdWHk50= +k8s.io/client-go v0.30.2/go.mod h1:JglKSWULm9xlJLx4KCkfLLQ7XwtlbflV6uFFSHTMgVs= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/images/cdi-artifact/pkg/common/common.go b/images/cdi-artifact/pkg/common/common.go new file mode 100644 index 0000000000..05a8406a9c --- /dev/null +++ b/images/cdi-artifact/pkg/common/common.go @@ -0,0 +1,67 @@ +package common + +import ( + "encoding/json" + "fmt" +) + +const ( + ImporterVolumePath = "/data" + DiskImageName = "disk.img" + ImporterWritePath = ImporterVolumePath + "/" + DiskImageName + WriteBlockPath = "/dev/pvc-importer-block-volume" + ImporterDataDir = "/data" + ScratchDataDir = "/scratch" + NbdkitLogPath = "/tmp/nbdkit.log" + + PodTerminationMessageFile = "/dev/termination-log" + + ImporterSource = "IMPORTER_SOURCE" + ImporterContentType = "IMPORTER_CONTENTTYPE" + ImporterEndpoint = "IMPORTER_ENDPOINT" + ImporterAccessKeyID = "IMPORTER_ACCESS_KEY_ID" + ImporterSecretKey = "IMPORTER_SECRET_KEY" + ImporterImageSize = "IMPORTER_IMAGE_SIZE" + ImporterCertDirVar = "IMPORTER_CERT_DIR" + ImporterDoneFile = "IMPORTER_DONE_FILE" + ImporterProxyCertDir = "/proxycerts/" + InsecureTLSVar = "INSECURE_TLS" + CacheMode = "CACHE_MODE" + CacheModeTryNone = "TRYNONE" + FilesystemOverheadVar = "FILESYSTEM_OVERHEAD" + OwnerUID = "OWNER_UID" + ImporterNBDEndpoint = "IMPORTER_NBD_ENDPOINT" + + GenericError = "Error" + PreallocationApplied = "Preallocation applied" + ScratchSpaceRequired = "scratch space required and none found" + ImagePullFailureText = "failed to pull image" +) + +// TerminationMessage contains data to be serialized and used as the termination message of the importer. +type TerminationMessage struct { + ScratchSpaceRequired *bool `json:"scratchSpaceRequired,omitempty"` + PreallocationApplied *bool `json:"preallocationApplied,omitempty"` + DeadlinePassed *bool `json:"deadlinePassed,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Message *string `json:"message,omitempty"` +} + +func (it *TerminationMessage) String() (string, error) { + msg, err := json.Marshal(it) + if err != nil { + return "", err + } + + // Messages longer than 4096 are truncated by kubelet. + if length := len(msg); length > 4096 { + return "", fmt.Errorf("termination message length %d exceeds maximum length of 4096 bytes", length) + } + + return string(msg), nil +} + +// ServerInfo contains data to be serialized and used as the body of responses to the container image server info endpoint. +type ServerInfo struct { + Env []string `json:"env,omitempty"` +} diff --git a/images/cdi-artifact/pkg/image/directio.go b/images/cdi-artifact/pkg/image/directio.go new file mode 100644 index 0000000000..d574052c91 --- /dev/null +++ b/images/cdi-artifact/pkg/image/directio.go @@ -0,0 +1,115 @@ +/* +Copyright 2023 The CDI Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package image + +import ( + "io" + "os" + "syscall" + + "github.com/pkg/errors" + "k8s.io/klog/v2" +) + +// OSInterface collects system level operations that need to be mocked out +// during tests. +type OSInterface interface { + Stat(path string) (os.FileInfo, error) + Remove(path string) error + OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) +} + +// RealOS is used to dispatch the real system level operations. +type RealOS struct{} + +// Stat will call os.Stat to get the FileInfo for a given path +func (RealOS) Stat(path string) (os.FileInfo, error) { + return os.Stat(path) +} + +// Remove will call os.Remove to remove the path. +func (RealOS) Remove(path string) error { + return os.Remove(path) +} + +// OpenFile will call os.OpenFile to return the file. +func (RealOS) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + return os.OpenFile(name, flag, perm) +} + +// DirectIOChecker checks if a certain destination supports direct I/O (bypassing page cache) +type DirectIOChecker interface { + CheckBlockDevice(path string) (bool, error) + CheckFile(path string) (bool, error) +} + +type directIOChecker struct { + OSInterface OSInterface +} + +// NewDirectIOChecker returns a new direct I/O checker +func NewDirectIOChecker(osInterface OSInterface) DirectIOChecker { + return &directIOChecker{ + OSInterface: osInterface, + } +} + +func (c *directIOChecker) CheckBlockDevice(path string) (bool, error) { + return c.check(path, syscall.O_RDONLY) +} + +func (c *directIOChecker) CheckFile(path string) (bool, error) { + flags := syscall.O_RDONLY + if _, err := c.OSInterface.Stat(path); errors.Is(err, os.ErrNotExist) { + // try to create the file and perform the check + flags |= syscall.O_CREAT + defer removeTempFileAndCheckErr(c.OSInterface, path) + } + return c.check(path, flags) +} + +// based on https://github.com/kubevirt/kubevirt/blob/c4fc4ab72a868399f5331438f35b8c33e7dd0720/pkg/virt-launcher/virtwrap/converter/converter.go#L346 +func (c *directIOChecker) check(path string, flags int) (bool, error) { + // #nosec No risk for path injection as we only open the file, not read from it. The function leaks only whether the directory to `path` exists. + f, err := c.OSInterface.OpenFile(path, flags|syscall.O_DIRECT, 0o600) + if err != nil { + // EINVAL is returned if the filesystem does not support the O_DIRECT flag + if perr := (&os.PathError{}); errors.As(err, &perr) && errors.Is(perr, syscall.EINVAL) { + // #nosec No risk for path injection as we only open the file, not read from it. The function leaks only whether the directory to `path` exists. + f, err := c.OSInterface.OpenFile(path, flags & ^syscall.O_DIRECT, 0o600) + if err == nil { + defer closeIOAndCheckErr(f) + return false, nil + } + } + return false, err + } + defer closeIOAndCheckErr(f) + return true, nil +} + +func closeIOAndCheckErr(c io.Closer) { + if ferr := c.Close(); ferr != nil { + klog.Errorf("Error when closing file: \n%s\n", ferr) + } +} + +func removeTempFileAndCheckErr(osInterface OSInterface, path string) { + if ferr := osInterface.Remove(path); ferr != nil { + klog.Errorf("Error when removing file: \n%s\n", ferr) + } +} diff --git a/images/cdi-artifact/pkg/image/filefmt.go b/images/cdi-artifact/pkg/image/filefmt.go new file mode 100644 index 0000000000..58f83818e3 --- /dev/null +++ b/images/cdi-artifact/pkg/image/filefmt.go @@ -0,0 +1,119 @@ +package image + +import ( + "bytes" + "encoding/hex" + "strconv" + + "github.com/pkg/errors" + "k8s.io/klog/v2" +) + +// MaxExpectedHdrSize defines the Size of buffer used to read file headers. +// Note: this is the size of tar's header. If a larger number is used the tar unarchive operation +// +// creates the destination file too large, by the difference between this const and 512. +const MaxExpectedHdrSize = 512 + +// Headers provides a map for header info, key is file format, eg. "gz" or "tar", value is metadata describing the layout for this hdr +type Headers map[string]Header + +var knownHeaders = Headers{ + "gz": Header{ + Format: "gz", + magicNumber: []byte{0x1F, 0x8B}, + // TODO: size not in hdr + SizeOff: 0, + SizeLen: 0, + }, + "zst": Header{ + Format: "zst", + magicNumber: []byte{0x28, 0xb5, 0x2f, 0xfd}, + SizeOff: 0, + SizeLen: 0, + }, + "qcow2": Header{ + Format: "qcow2", + magicNumber: []byte{'Q', 'F', 'I', 0xfb}, + mgOffset: 0, + SizeOff: 24, + SizeLen: 8, + }, + "tar": Header{ + Format: "tar", + magicNumber: []byte{0x75, 0x73, 0x74, 0x61, 0x72}, + mgOffset: 0x101, + SizeOff: 124, + SizeLen: 8, + }, + "xz": Header{ + Format: "xz", + magicNumber: []byte{0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00}, + // TODO: size not in hdr + SizeOff: 0, + SizeLen: 0, + }, + "vmdk": Header{ + Format: "vmdk", + magicNumber: []byte("KDMV"), + SizeOff: 0, + SizeLen: 0, + }, + "vdi": Header{ + Format: "vdi", + magicNumber: []byte{0x7F, 0x10, 0xDA, 0xBE}, + mgOffset: 0x40, + SizeOff: 0, + SizeLen: 0, + }, + "vhd": Header{ + Format: "vhd", + magicNumber: []byte("conectix"), + SizeOff: 0, + SizeLen: 0, + }, + "vhdx": Header{ + Format: "vhdx", + magicNumber: []byte("vhdxfile"), + SizeOff: 0, + SizeLen: 0, + }, +} + +// Header represents our parameters for a file format header +type Header struct { + Format string + magicNumber []byte + mgOffset int + SizeOff int // in bytes + SizeLen int // in bytes +} + +// CopyKnownHdrs performs a simple map copy since := assignment copies the reference to the map, not contents. +func CopyKnownHdrs() Headers { + m := make(Headers) + for k, v := range knownHeaders { + m[k] = v + } + return m +} + +// Match performs a check to see if the provided byte slice matches the bytes in our header data +func (h Header) Match(b []byte) bool { + return bytes.Equal(b[h.mgOffset:h.mgOffset+len(h.magicNumber)], h.magicNumber) +} + +// Size uses the Header receiver offset and length fields to extract, from the passed-in file header slice (b), +// the size of the original file. It is not guaranteed that the header is known to cdi and thus 0 may be returned as the size. +func (h Header) Size(b []byte) (int64, error) { + if h.SizeLen == 0 { // no size is supported in this format's header + return 0, nil + } + s := hex.EncodeToString(b[h.SizeOff : h.SizeOff+h.SizeLen]) + size, err := strconv.ParseInt(s, 16, 64) + if err != nil { + return 0, errors.Wrapf(err, "unable to determine original file size from %+v", s) + } + klog.V(3).Infof("Size: %q size in bytes (at off %d:%d): %d", h.Format, h.SizeOff, h.SizeOff+h.SizeLen, size) + return size, nil +} diff --git a/images/cdi-artifact/pkg/image/nbdkit.go b/images/cdi-artifact/pkg/image/nbdkit.go new file mode 100644 index 0000000000..702b22e4f3 --- /dev/null +++ b/images/cdi-artifact/pkg/image/nbdkit.go @@ -0,0 +1,324 @@ +package image + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/pkg/errors" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" +) + +const ( + startupTimeoutSeconds = 15 + defaultUserAgent = "cdi-nbdkit-importer" +) + +// NbdkitPlugin represents a plugin for nbdkit +type NbdkitPlugin string + +// NbdkitFilter represents s filter for nbdkit +type NbdkitFilter string + +// NbdkitLogWatcher allows custom handling of nbdkit log messages +type NbdkitLogWatcher interface { + Start(*bufio.Reader) + Stop() +} + +// Nbdkit plugins +const ( + NbdkitCurlPlugin NbdkitPlugin = "curl" +) + +// Nbdkit filters +const ( + NbdkitRetryFilter NbdkitFilter = "retry" + NbdkitReadAheadFilter NbdkitFilter = "readahead" +) + +// Nbdkit represents struct for an nbdkit instance +type Nbdkit struct { + c *exec.Cmd + NbdPidFile string + nbdkitArgs []string + plugin NbdkitPlugin + pluginArgs []string + redactArgs []string + filters []NbdkitFilter + Socket string + Env []string + LogWatcher NbdkitLogWatcher +} + +// NbdkitOperation defines the interface for executing nbdkit +type NbdkitOperation interface { + StartNbdkit(source string) error + KillNbdkit() error + AddEnvVariable(v string) + AddFilter(filter NbdkitFilter) +} + +// NewNbdkitCurl creates a new Nbdkit instance with the curl plugin +func NewNbdkitCurl(nbdkitPidFile, user, password, certDir, socket string, extraHeaders, secretExtraHeaders []string) (NbdkitOperation, error) { + var pluginArgs []string + var redactArgs []string + args := []string{"-r"} + pluginArgs = append(pluginArgs, fmt.Sprintf("header=User-Agent: %s", defaultUserAgent)) + if user != "" { + pluginArgs = append(pluginArgs, "user="+user) + } + if password != "" { + passwordfile, err := writePasswordFile(password) + if err != nil { + return nil, err + } + pluginArgs = append(pluginArgs, "password=+"+passwordfile) + } + if certDir != "" { + pluginArgs = append(pluginArgs, fmt.Sprintf("cainfo=%s/%s", certDir, "tls.crt")) + } + for _, header := range extraHeaders { + pluginArgs = append(pluginArgs, fmt.Sprintf("header=%s", header)) + } + // Don't do exponential retry, the container restart will be exponential + pluginArgs = append(pluginArgs, "retry-exponential=no") + for _, header := range secretExtraHeaders { + redactArgs = append(redactArgs, fmt.Sprintf("header=%s", header)) + } + n := &Nbdkit{ + NbdPidFile: nbdkitPidFile, + plugin: NbdkitCurlPlugin, + nbdkitArgs: args, + pluginArgs: pluginArgs, + redactArgs: redactArgs, + Socket: socket, + } + // QEMU generally reads through the extents in order, making the readahead + // filter quite appropriate. + n.AddFilter(NbdkitReadAheadFilter) + // Should be last filter + n.AddFilter(NbdkitRetryFilter) + return n, nil +} + +func writePasswordFile(password string) (string, error) { + f, err := os.CreateTemp("", "password") + if err != nil { + return "", err + } + defer f.Close() + // This would be nice but we don't want to delete it until + // program exit. + // defer os.Remove(f.Name()) + + if _, err := f.Write([]byte(password)); err != nil { + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + return f.Name(), nil +} + +// AddEnvVariable adds an environmental variable to the nbdkit command +func (n *Nbdkit) AddEnvVariable(v string) { + env := os.Environ() + env = append(env, v) + n.Env = env +} + +// AddFilter adds a nbdkit filter if it doesn't already exist +func (n *Nbdkit) AddFilter(filter NbdkitFilter) { + for _, f := range n.filters { + if f == filter { + return + } + } + n.filters = append(n.filters, filter) +} + +func (n *Nbdkit) getSourceArg(s string) string { + var source string + switch n.plugin { + case NbdkitCurlPlugin: + source = fmt.Sprintf("url=%s", s) + default: + source = s + } + return source +} + +// StartNbdkit starts nbdkit process +func (n *Nbdkit) StartNbdkit(source string) error { + var err error + argsNbdkit := []string{ + "--foreground", + "--readonly", + "--exit-with-parent", + "-U", n.Socket, + "--pidfile", n.NbdPidFile, + } + // set filters + for _, f := range n.filters { + argsNbdkit = append(argsNbdkit, fmt.Sprintf("--filter=%s", f)) + } + // set additional arguments + argsNbdkit = append(argsNbdkit, n.nbdkitArgs...) + + // append nbdkit plugin arguments + argsNbdkit = append(argsNbdkit, string(n.plugin)) + argsNbdkit = append(argsNbdkit, n.pluginArgs...) + argsNbdkit = append(argsNbdkit, n.redactArgs...) + argsNbdkit = append(argsNbdkit, n.getSourceArg(source)) + + isRedacted := func(arg string) bool { + for _, value := range n.redactArgs { + if value == arg { + return true + } + } + return false + } + + quotedArgs := make([]string, len(argsNbdkit)) + for index, value := range argsNbdkit { + if isRedacted(value) { + if strings.HasPrefix(value, "header=") { + quotedArgs[index] = "'header=/secret redacted/'" + } else { + quotedArgs[index] = "'/secret redacted/'" + } + } else { + quotedArgs[index] = "'" + value + "'" + } + } + klog.V(3).Infof("Start nbdkit with: %v", quotedArgs) + + n.c = exec.Command("nbdkit", argsNbdkit...) + var stdout io.ReadCloser + stdout, err = n.c.StdoutPipe() + if err != nil { + klog.Errorf("Error constructing stdout pipe: %v", err) + return err + } + n.c.Stderr = n.c.Stdout + output := bufio.NewReader(stdout) + if n.LogWatcher != nil { + n.LogWatcher.Start(output) + } else { + go watchNbdLog(output) + } + + err = n.c.Start() + if err != nil { + klog.Errorf("Unable to start nbdkit: %v", err) + return err + } + + err = waitForNbd(n.NbdPidFile) + if err != nil { + klog.Errorf("Failed waiting for nbdkit to start up: %v", err) + return err + } + return nil +} + +// Default nbdkit log watcher, logs lines as nbdkit prints them, +// and appends them to the nbdkit log file. +func watchNbdLog(output *bufio.Reader) { + f, err := os.Create(common.NbdkitLogPath) + if err != nil { + klog.Errorf("Error writing nbdkit log to file: %v", err) + } + defer f.Close() + + scanner := bufio.NewScanner(output) + for scanner.Scan() { + line := scanner.Text() + logLine := fmt.Sprintf("Log line from nbdkit: %s", line) + klog.Info(logLine) + _, err = f.WriteString(logLine) + if err != nil { + klog.Errorf("failed to write log line; %v", err) + } + } + if err := scanner.Err(); err != nil { + klog.Errorf("Error watching nbdkit log: %v", err) + } + klog.Infof("Stopped watching nbdkit log.") +} + +// waitForNbd waits for nbdkit to start by watching for the existence of the given PID file. +func waitForNbd(pidfile string) error { + nbdCheck := make(chan bool, 1) + go func() { + klog.Infoln("Waiting for nbdkit PID.") + for { + select { + case <-nbdCheck: + return + case <-time.After(500 * time.Millisecond): + _, err := os.Stat(pidfile) + if err != nil { + if !os.IsNotExist(err) { + klog.Warningf("Error checking for nbdkit PID: %v", err) + } + } else { + nbdCheck <- true + return + } + } + } + }() + + select { + case <-nbdCheck: + klog.Infoln("nbdkit ready.") + return nil + case <-time.After(startupTimeoutSeconds * time.Second): + nbdCheck <- true + return errors.New("timed out waiting for nbdkit to be ready") + } +} + +// KillNbdkit stops the nbdkit process +func (n *Nbdkit) KillNbdkit() error { + var err error + if n.c == nil { + return nil + } + if n.c.Process != nil { + err = n.c.Process.Signal(os.Interrupt) + if err != nil { + err = n.c.Process.Kill() + } + } + if n.LogWatcher != nil { + n.LogWatcher.Stop() + } + return err +} + +type mockNbdkit struct{} + +// NewMockNbdkitCurl creates a mock nbdkit curl plugin for testing +func NewMockNbdkitCurl(nbdkitPidFile, user, password, certDir, socket string, extraHeaders, secretExtraHeaders []string) (NbdkitOperation, error) { + return &mockNbdkit{}, nil +} + +func (m *mockNbdkit) StartNbdkit(source string) error { + return nil +} + +func (m *mockNbdkit) KillNbdkit() error { + return nil +} +func (m *mockNbdkit) AddEnvVariable(v string) {} +func (m *mockNbdkit) AddFilter(filter NbdkitFilter) {} diff --git a/images/cdi-artifact/pkg/image/qemu.go b/images/cdi-artifact/pkg/image/qemu.go new file mode 100644 index 0000000000..22f0d1a084 --- /dev/null +++ b/images/cdi-artifact/pkg/image/qemu.go @@ -0,0 +1,410 @@ +/* +Copyright 2018 The CDI Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package image + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "regexp" + "strconv" + "strings" + + "github.com/docker/go-units" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" + metrics "kubevirt.io/containerized-data-importer/pkg/monitoring/metrics/pvc-importer" + "kubevirt.io/containerized-data-importer/pkg/system" + "kubevirt.io/containerized-data-importer/pkg/util" +) + +const ( + networkTimeoutSecs = 3600 // max is 10000 + maxMemory = 1 << 30 // value from OpenStack Nova + maxCPUSecs = 30 // value from OpenStack Nova + matcherString = "\\((\\d?\\d\\.\\d\\d)\\/100%\\)" +) + +// ImgInfo contains the virtual image information. +type ImgInfo struct { + // Format contains the format of the image + Format string `json:"format"` + // BackingFile is the file name of the backing file + BackingFile string `json:"backing-filename"` + // VirtualSize is the disk size of the image which will be read by vm + VirtualSize int64 `json:"virtual-size"` + // ActualSize is the size of the qcow2 image + ActualSize int64 `json:"actual-size"` +} + +// QEMUOperations defines the interface for executing qemu subprocesses +type QEMUOperations interface { + ConvertToRawStream(*url.URL, string, bool, string) error + ConvertToFormatStream(url *url.URL, format, dest string, preallocate bool) error + Resize(string, resource.Quantity, bool) error + Info(url *url.URL) (*ImgInfo, error) + Validate(*url.URL, int64) error + CreateBlankImage(string, resource.Quantity, bool) error + Rebase(backingFile, delta string) error + Commit(image string) error +} + +type qemuOperations struct{} + +var ( + qemuExecFunction = system.ExecWithLimits + qemuInfoLimits = &system.ProcessLimitValues{AddressSpaceLimit: maxMemory, CPUTimeLimit: maxCPUSecs} + qemuIterface = NewQEMUOperations() + re = regexp.MustCompile(matcherString) + + ownerUID string + convertPreallocationMethods = [][]string{ + {"-o", "preallocation=falloc"}, + {"-o", "preallocation=full"}, + {"-S", "0"}, + } + resizePreallocationMethods = [][]string{ + {"--preallocation=falloc"}, + {"--preallocation=full"}, + } + odirectChecker = NewDirectIOChecker(RealOS{}) +) + +func init() { + if err := metrics.SetupMetrics(); err != nil { + klog.Errorf("Unable to create prometheus progress counter: %v", err) + } + ownerUID, _ = util.ParseEnvVar(common.OwnerUID, false) +} + +// NewQEMUOperations returns the default implementation of QEMUOperations +func NewQEMUOperations() QEMUOperations { + return &qemuOperations{} +} + +func convertToRaw(src, dest string, preallocate bool, cacheMode string) error { + cacheMode, err := getCacheMode(dest, cacheMode) + if err != nil { + return err + } + args := []string{"convert", "-t", cacheMode, "-p", "-O", "raw", src, dest} + + if preallocate { + err = addPreallocation(args, convertPreallocationMethods, func(args []string) ([]byte, error) { + return qemuExecFunction(nil, reportProgress, "qemu-img", args...) + }) + } else { + klog.V(1).Infof("Running qemu-img with args: %v", args) + _, err = qemuExecFunction(nil, reportProgress, "qemu-img", args...) + } + if err != nil { + _ = os.Remove(dest) + errorMsg := "could not convert image to raw" + if nbdkitLog, err := os.ReadFile(common.NbdkitLogPath); err == nil { + errorMsg += " " + string(nbdkitLog) + } + return errors.Wrap(err, errorMsg) + } + + return nil +} + +func getCacheMode(path, cacheMode string) (string, error) { + if cacheMode != common.CacheModeTryNone { + return "writeback", nil + } + + var supportDirectIO bool + isDevice, err := util.IsDevice(path) + if err != nil { + return "", err + } + + if isDevice { + supportDirectIO, err = odirectChecker.CheckBlockDevice(path) + } else { + supportDirectIO, err = odirectChecker.CheckFile(path) + } + if err != nil { + return "", err + } + + if supportDirectIO { + return "none", nil + } + + return "writeback", nil +} + +func (o *qemuOperations) ConvertToRawStream(url *url.URL, dest string, preallocate bool, cacheMode string) error { + if len(url.Scheme) > 0 && url.Scheme != "nbd+unix" { + return fmt.Errorf("not valid schema %s", url.Scheme) + } + return convertToRaw(url.String(), dest, preallocate, cacheMode) +} + +// convertQuantityToQemuSize translates a quantity string into a Qemu compatible string. +func convertQuantityToQemuSize(size resource.Quantity) string { + int64Size, asInt := size.AsInt64() + if !asInt { + size.AsDec().SetScale(0) + return size.AsDec().String() + } + return strconv.FormatInt(int64Size, 10) +} + +// Resize resizes the given image to size +func Resize(image string, size resource.Quantity, preallocate bool) error { + return qemuIterface.Resize(image, size, preallocate) +} + +func (o *qemuOperations) Resize(image string, size resource.Quantity, preallocate bool) error { + var err error + args := []string{"resize", "-f", "raw", image, convertQuantityToQemuSize(size)} + if preallocate { + err = addPreallocation(args, resizePreallocationMethods, func(args []string) ([]byte, error) { + return qemuExecFunction(nil, nil, "qemu-img", args...) + }) + } else { + _, err = qemuExecFunction(nil, nil, "qemu-img", args...) + } + if err != nil { + return errors.Wrapf(err, "Error resizing image %s", image) + } + return nil +} + +func checkOutputQemuImgInfo(output []byte, image string) (*ImgInfo, error) { + var info ImgInfo + err := json.Unmarshal(output, &info) + if err != nil { + klog.Errorf("Invalid JSON:\n%s\n", string(output)) + return nil, errors.Wrapf(err, "Invalid json for image %s", image) + } + return &info, nil +} + +// Info returns information about the image from the url +func Info(url *url.URL) (*ImgInfo, error) { + return qemuIterface.Info(url) +} + +func (o *qemuOperations) Info(url *url.URL) (*ImgInfo, error) { + if len(url.Scheme) > 0 && url.Scheme != "nbd+unix" && url.Scheme != "file" { + return nil, fmt.Errorf("not valid schema %s", url.Scheme) + } + output, err := qemuExecFunction(qemuInfoLimits, nil, "qemu-img", "info", "--output=json", url.String()) + if err != nil { + errorMsg := fmt.Sprintf("%s, %s", output, err.Error()) + if nbdkitLog, err := os.ReadFile(common.NbdkitLogPath); err == nil { + errorMsg += " " + string(nbdkitLog) + } + return nil, errors.New(errorMsg) + } + return checkOutputQemuImgInfo(output, url.String()) +} + +func isSupportedFormat(value string) bool { + switch value { + case "raw", "qcow2", "vmdk", "vdi", "vpc", "vhdx": + return true + default: + return false + } +} + +func checkIfURLIsValid(info *ImgInfo, availableSize int64, image string) error { + if !isSupportedFormat(info.Format) { + return errors.Errorf("Invalid format %s for image %s", info.Format, image) + } + + if len(info.BackingFile) > 0 { + if _, err := os.Stat(info.BackingFile); err != nil { + return errors.Errorf("Image %s is invalid because it has invalid backing file %s", image, info.BackingFile) + } + } + + if availableSize < info.VirtualSize { + return errors.Errorf("virtual image size %d is larger than the reported available storage %d. A larger PVC is required", info.VirtualSize, availableSize) + } + return nil +} + +func (o *qemuOperations) Validate(url *url.URL, availableSize int64) error { + info, err := o.Info(url) + if err != nil { + return err + } + return checkIfURLIsValid(info, availableSize, url.String()) +} + +// ConvertToRawStream converts an http accessible image to raw format without locally caching the image +func ConvertToRawStream(url *url.URL, dest string, preallocate bool, cacheMode string) error { + return qemuIterface.ConvertToRawStream(url, dest, preallocate, cacheMode) +} + +// Validate does basic validation of a qemu image +func Validate(url *url.URL, availableSize int64) error { + return qemuIterface.Validate(url, availableSize) +} + +// convertProgressBase is the offset that the Convert phase contribution starts +// from in the overall import_progress counter (0..100). The TransferScratch +// phase occupies 0..convertProgressBase; the Convert phase fills +// convertProgressBase..100. The actual fraction reported by qemu-img is +// halved when projected (see reportProgress). +const convertProgressBase = 50.0 + +func reportProgress(line string) { + reportProgressScaled(line, convertProgressBase, 100.0) +} + +func reportProgressFull(line string) { + reportProgressScaled(line, 0, 100.0) +} + +func reportProgressScaled(line string, low, high float64) { + // (45.34/100%) + matches := re.FindStringSubmatch(line) + if len(matches) == 2 && ownerUID != "" { + klog.V(1).Info(matches[1]) + // Don't need to check for an error, the regex made sure its a number we can parse. + v, _ := strconv.ParseFloat(matches[1], 64) + if v <= 0 { + return + } + scaled := low + v*((high-low)/100.0) + progress, err := metrics.Progress(ownerUID).Get() + if err == nil && scaled > progress { + metrics.Progress(ownerUID).Add(scaled - progress) + } + } +} + +// CreateBlankImage creates empty raw image +func CreateBlankImage(dest string, size resource.Quantity, preallocate bool) error { + klog.V(1).Infof("creating raw image with size %s, preallocation %v", size.String(), preallocate) + return qemuIterface.CreateBlankImage(dest, size, preallocate) +} + +// CreateBlankImage creates a raw image with a given size +func (o *qemuOperations) CreateBlankImage(dest string, size resource.Quantity, preallocate bool) error { + format, err := util.GetFormat(dest) + if err != nil { + return err + } + + klog.V(3).Infof("image size is %s", size.String()) + args := []string{"create", "-f", format, dest, convertQuantityToQemuSize(size)} + if preallocate { + klog.V(1).Infof("Added preallocation") + args = append(args, []string{"-o", "preallocation=falloc"}...) + } + _, err = qemuExecFunction(nil, nil, "qemu-img", args...) + if err != nil { + _ = os.Remove(dest) + return errors.Wrap(err, fmt.Sprintf("could not create raw image with size %s in %s", size.String(), dest)) + } + // Change permissions to 0660 + err = os.Chmod(dest, 0o660) + if err != nil { + err = errors.Wrap(err, "Unable to change permissions of target file") + return err + } + + return nil +} + +func execPreallocationBlock(dest string, bs, count, offset int64) error { + oflag := "oflag=seek_bytes" + supportDirectIO, err := odirectChecker.CheckBlockDevice(dest) + if err != nil { + return err + } + if supportDirectIO { + oflag += ",direct" + } + args := []string{"if=/dev/zero", "of=" + dest, fmt.Sprintf("bs=%d", bs), fmt.Sprintf("count=%d", count), fmt.Sprintf("seek=%d", offset), oflag} + _, err = qemuExecFunction(nil, nil, "dd", args...) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Could not preallocate blank block volume at %s, running dd for size %d, offset %d", dest, bs*count, offset)) + } + + return nil +} + +// PreallocateBlankBlock writes requested amount of zeros to block device mounted at dest +func PreallocateBlankBlock(dest string, size resource.Quantity) error { + klog.V(3).Infof("block volume size is %s", size.String()) + + qemuSize, err := strconv.ParseInt(convertQuantityToQemuSize(size), 10, 64) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Could not parse size for preallocating blank block volume at %s with size %s", dest, size.String())) + } + countBlocks, remainder := qemuSize/units.MiB, qemuSize%units.MiB + err = execPreallocationBlock(dest, units.MiB, countBlocks, 0) + if err != nil { + return err + } + if remainder != 0 { + return execPreallocationBlock(dest, remainder, 1, countBlocks*units.MiB) + } + return nil +} + +func addPreallocation(args []string, preallocationMethods [][]string, qemuFn func(args []string) ([]byte, error)) error { + var err error + for _, preallocationMethod := range preallocationMethods { + var output []byte + + klog.V(1).Infof("Adding preallocation method: %v", preallocationMethod) + // For some subcommands (e.g. resize), preallocation optinos must come before other options + argsToTry := append([]string{args[0]}, preallocationMethod...) + argsToTry = append(argsToTry, args[1:]...) + klog.V(1).Infof("Attempting preallocation method, qemu-img args: %v", argsToTry) + + output, err = qemuFn(argsToTry) + if err != nil && strings.Contains(string(output), "Unsupported preallocation mode") { + klog.V(1).Infof("Unsupported preallocation mode. Retrying") + } else { + break + } + } + + return err +} + +// Rebase changes a QCOW's backing file to point to a previously-downloaded base image. +// Depends on original image having been downloaded as raw. +func (o *qemuOperations) Rebase(backingFile, delta string) error { + klog.V(1).Infof("Rebasing %s onto %s", delta, backingFile) + args := []string{"rebase", "-p", "-u", "-F", "raw", "-b", backingFile, delta} + _, err := qemuExecFunction(nil, reportProgress, "qemu-img", args...) + return err +} + +// Commit takes the changes written to a QCOW and applies them to its raw backing file. +func (o *qemuOperations) Commit(image string) error { + klog.V(1).Infof("Committing %s to backing file...", image) + args := []string{"commit", "-p", image} + _, err := qemuExecFunction(nil, reportProgress, "qemu-img", args...) + return err +} diff --git a/images/cdi-artifact/pkg/image/qemu_format_stream.go b/images/cdi-artifact/pkg/image/qemu_format_stream.go new file mode 100644 index 0000000000..1930b56054 --- /dev/null +++ b/images/cdi-artifact/pkg/image/qemu_format_stream.go @@ -0,0 +1,49 @@ +package image + +import ( + "fmt" + "net/url" + "os" + + "github.com/pkg/errors" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" +) + +func convertTo(format, src, dest string, preallocate bool) error { + switch format { + case "qcow2", "raw": + // Do nothing. + default: + return errors.Errorf("unknown format: %s", format) + } + args := []string{"convert", "-t", "writeback", "-p", "-O", format, src, dest} + var err error + + if preallocate { + err = addPreallocation(args, convertPreallocationMethods, func(args []string) ([]byte, error) { + return qemuExecFunction(nil, reportProgress, "qemu-img", args...) + }) + } else { + klog.V(1).Infof("Running qemu-img with args: %v", args) + _, err = qemuExecFunction(nil, reportProgress, "qemu-img", args...) + } + if err != nil { + _ = os.Remove(dest) + errorMsg := fmt.Sprintf("could not convert image to %s", format) + if nbdkitLog, err := os.ReadFile(common.NbdkitLogPath); err == nil { + errorMsg += " " + string(nbdkitLog) + } + return errors.Wrap(err, errorMsg) + } + + return nil +} + +func (o *qemuOperations) ConvertToFormatStream(url *url.URL, format, dest string, preallocate bool) error { + if len(url.Scheme) > 0 && url.Scheme != "nbd+unix" { + return fmt.Errorf("not valid schema %s", url.Scheme) + } + return convertTo(format, url.String(), dest, preallocate) +} diff --git a/images/cdi-artifact/pkg/image/qemu_progress_test.go b/images/cdi-artifact/pkg/image/qemu_progress_test.go new file mode 100644 index 0000000000..2595e35293 --- /dev/null +++ b/images/cdi-artifact/pkg/image/qemu_progress_test.go @@ -0,0 +1,50 @@ +package image + +import ( + "testing" + + metrics "kubevirt.io/containerized-data-importer/pkg/monitoring/metrics/pvc-importer" +) + +func TestReportProgressFullUpdatesImportMetric(t *testing.T) { + uid := "report-progress-full" + ownerUID = uid + metrics.Progress(uid).Delete() + + reportProgressFull("(0.00/100%)") + if got, err := metrics.Progress(uid).Get(); err != nil || got != 0 { + t.Fatalf("expected 0 after zero qemu progress, got %v err=%v", got, err) + } + + reportProgressFull("(50.00/100%)") + if got, err := metrics.Progress(uid).Get(); err != nil || got != 50 { + t.Fatalf("expected 50, got %v err=%v", got, err) + } + + reportProgressFull("(75.00/100%)") + if got, err := metrics.Progress(uid).Get(); err != nil || got != 75 { + t.Fatalf("expected 75, got %v err=%v", got, err) + } + + reportProgressFull("(25.00/100%)") + if got, err := metrics.Progress(uid).Get(); err != nil || got != 75 { + t.Fatalf("expected progress to stay monotonic at 75, got %v err=%v", got, err) + } +} + +func TestReportProgressScalesConvertPhaseIntoUpperHalf(t *testing.T) { + uid := "report-progress-convert" + ownerUID = uid + metrics.Progress(uid).Delete() + + reportProgress("(99.99/100%)") + if got, err := metrics.Progress(uid).Get(); err != nil || got < 99.9 { + t.Fatalf("expected ~100 at end of convert phase, got %v err=%v", got, err) + } + + metrics.Progress(uid).Delete() + reportProgress("(50.00/100%)") + if got, err := metrics.Progress(uid).Get(); err != nil || got != 75 { + t.Fatalf("expected 75 for halfway qemu convert, got %v err=%v", got, err) + } +} diff --git a/images/cdi-artifact/pkg/image/validate.go b/images/cdi-artifact/pkg/image/validate.go new file mode 100644 index 0000000000..845c4834d2 --- /dev/null +++ b/images/cdi-artifact/pkg/image/validate.go @@ -0,0 +1,30 @@ +package image + +const ( + // ExtImg is a constant for the .img extenstion + ExtImg = ".img" + // ExtIso is a constant for the .iso extenstion + ExtIso = ".iso" + // ExtGz is a constant for the .gz extenstion + ExtGz = ".gz" + // ExtQcow2 is a constant for the .qcow2 extenstion + ExtQcow2 = ".qcow2" + // ExtVmdk is a constant for the .vmdk VMware extenstion + ExtVmdk = ".vmdk" + // ExtVdi is a constant for the .vdi VirtualBox extenstion + ExtVdi = ".vdi" + // ExtVhd is a constant for the .vhd Microsoft Virtual Server Virtual Hard Disk extenstion + ExtVhd = ".vhd" + // ExtVhdx is a constant for the .vhd Hyper-V Virtual Hard Disk V.2 extenstion + ExtVhdx = ".vhdx" + // ExtTar is a constant for the .tar extenstion + ExtTar = ".tar" + // ExtXz is a constant for the .xz extenstion + ExtXz = ".xz" + // ExtZst is a constant for the .zst extenstion + ExtZst = ".zst" + // ExtTarXz is a constant for the .tar.xz extenstion + ExtTarXz = ExtTar + ExtXz + // ExtTarGz is a constant for the .tar.gz extenstion + ExtTarGz = ExtTar + ExtGz +) diff --git a/images/cdi-artifact/pkg/importer/data-processor.go b/images/cdi-artifact/pkg/importer/data-processor.go new file mode 100644 index 0000000000..b907d594b3 --- /dev/null +++ b/images/cdi-artifact/pkg/importer/data-processor.go @@ -0,0 +1,407 @@ +/* +Copyright 2018 The CDI Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package importer + +import ( + "net/url" + "os" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" + "kubevirt.io/containerized-data-importer/pkg/image" + "kubevirt.io/containerized-data-importer/pkg/util" +) + +var qemuOperations = image.NewQEMUOperations() + +// ProcessingPhase is the current phase being processed. +type ProcessingPhase string + +const ( + // ProcessingPhaseInfo is the first phase, during this phase the source obtains information needed to determine which phase to go to next. + ProcessingPhaseInfo ProcessingPhase = "Info" + // ProcessingPhaseTransferScratch is the phase in which the data source writes data to the scratch space. + ProcessingPhaseTransferScratch ProcessingPhase = "TransferScratch" + // ProcessingPhaseTransferDataDir is the phase in which the data source writes data directly to the target path without conversion. + ProcessingPhaseTransferDataDir ProcessingPhase = "TransferDataDir" + // ProcessingPhaseTransferDataFile is the phase in which the data source writes data directly to the target file without conversion. + ProcessingPhaseTransferDataFile ProcessingPhase = "TransferDataFile" + // ProcessingPhaseValidatePause is the phase in which the data processor should validate and then pause. + ProcessingPhaseValidatePause ProcessingPhase = "ValidatePause" + // ProcessingPhaseConvert is the phase in which the data is taken from the url provided by the source, and it is converted to the target RAW disk image format. + // The url can be an http end point or file system end point. + ProcessingPhaseConvert ProcessingPhase = "Convert" + // ProcessingPhaseResize the disk image, this is only needed when the target contains a file system (block device do not need a resize) + ProcessingPhaseResize ProcessingPhase = "Resize" + // ProcessingPhaseComplete is the phase where the entire process completed successfully and we can exit gracefully. + ProcessingPhaseComplete ProcessingPhase = "Complete" + // ProcessingPhasePause is the phase where we pause processing and end the loop, and expect something to call the process loop again. + ProcessingPhasePause ProcessingPhase = "Pause" + // ProcessingPhaseError is the phase in which we encountered an error and need to exit ungracefully. + ProcessingPhaseError ProcessingPhase = common.GenericError + // ProcessingPhaseMergeDelta is the phase in a multi-stage import where a delta image downloaded to scratch is applied to the base image + ProcessingPhaseMergeDelta ProcessingPhase = "MergeDelta" +) + +// may be overridden in tests +var ( + getAvailableSpaceBlockFunc = util.GetAvailableSpaceBlock + getAvailableSpaceFunc = util.GetAvailableSpace +) + +// DataSourceInterface is the interface all data sources should implement. +type DataSourceInterface interface { + // Info is called to get initial information about the data. + Info() (ProcessingPhase, error) + // Transfer is called to transfer the data from the source to the path passed in. + Transfer(path string) (ProcessingPhase, error) + // TransferFile is called to transfer the data from the source to the file passed in. + TransferFile(fileName string) (ProcessingPhase, error) + // Geturl returns the url that the data processor can use when converting the data. + GetURL() *url.URL + // GetTerminationMessage returns data to be serialized and used as the termination message of the importer. + GetTerminationMessage() *common.TerminationMessage + // Close closes any readers or other open resources. + Close() error +} + +// ResumableDataSource is the interface all resumeable data sources should implement +type ResumableDataSource interface { + DataSourceInterface + GetResumePhase() ProcessingPhase +} + +// DataProcessor holds the fields needed to process data from a data provider. +type DataProcessor struct { + // currentPhase is the phase the processing is in currently. + currentPhase ProcessingPhase + // provider provides the data for processing. + source DataSourceInterface + // destination file. will be DataDir/disk.img if file system, or a block device (if a block device, then DataDir will not exist). + dataFile string + // dataDir path to target directory if it contains a file system. + dataDir string + // scratchDataDir path to the scratch space. + scratchDataDir string + // requestImageSize is the size we want the resulting image to be. + requestImageSize string + // available space is the available space before downloading the image + availableSpace int64 + // storage overhead is the amount of overhead of the storage used + filesystemOverhead float64 + // preallocation is the flag controlling preallocation setting of qemu-img + preallocation bool + // preallocationApplied is used to pass information whether preallocation has been performed, or not + preallocationApplied bool + // phaseExecutors is a mapping from the given processing phase to its execution function. The function returns the next processing phase or error. + phaseExecutors map[ProcessingPhase]func() (ProcessingPhase, error) + // cacheMode is the mode in which we choose the qemu-img cache mode: + // TRY_NONE = bypass page cache if the target supports it, otherwise, fall back to using page cache + cacheMode string +} + +// NewDataProcessor create a new instance of a data processor using the passed in data provider. +func NewDataProcessor(dataSource DataSourceInterface, dataFile, dataDir, scratchDataDir, requestImageSize string, filesystemOverhead float64, preallocation bool, cacheMode string) *DataProcessor { + dp := &DataProcessor{ + currentPhase: ProcessingPhaseInfo, + source: dataSource, + dataFile: dataFile, + dataDir: dataDir, + scratchDataDir: scratchDataDir, + requestImageSize: requestImageSize, + filesystemOverhead: filesystemOverhead, + preallocation: preallocation, + cacheMode: cacheMode, + } + // Calculate available space before doing anything. + dp.availableSpace = dp.calculateTargetSize() + dp.initDefaultPhases() + return dp +} + +// RegisterPhaseExecutor registers an execution function for the given phase. +// If there is already an function registered, override it with the new function. +func (dp *DataProcessor) RegisterPhaseExecutor(pp ProcessingPhase, executor func() (ProcessingPhase, error)) { + if _, ok := dp.phaseExecutors[pp]; ok { + klog.Warningf("Executor already exists at phase %s. Override it.", pp) + } + dp.phaseExecutors[pp] = executor +} + +// ProcessData is the main synchronous processing loop +func (dp *DataProcessor) ProcessData() error { + return dp.ProcessDataWithPause() +} + +// ProcessDataResume Resume a paused processor, assumes the provided data source is ResumableDataSource +func (dp *DataProcessor) ProcessDataResume() error { + rds, ok := dp.source.(ResumableDataSource) + if !ok { + return errors.New("Datasource not resumable") + } + klog.Infof("Resuming processing at phase %s", rds.GetResumePhase()) + dp.currentPhase = rds.GetResumePhase() + return dp.ProcessDataWithPause() +} + +func (dp *DataProcessor) initDefaultPhases() { + dp.phaseExecutors = make(map[ProcessingPhase]func() (ProcessingPhase, error)) + dp.RegisterPhaseExecutor(ProcessingPhaseInfo, func() (ProcessingPhase, error) { + pp, err := dp.source.Info() + if err != nil { + err = errors.Wrap(err, "Unable to obtain information about data source") + } + return pp, err + }) + dp.RegisterPhaseExecutor(ProcessingPhaseTransferScratch, func() (ProcessingPhase, error) { + pp, err := dp.source.Transfer(dp.scratchDataDir) + if errors.Is(err, ErrInvalidPath) { + // Passed in invalid scratch space path, return scratch space needed error. + err = ErrRequiresScratchSpace + } else if err != nil { + err = errors.Wrap(err, "Unable to transfer source data to scratch space") + } + return pp, err + }) + dp.RegisterPhaseExecutor(ProcessingPhaseTransferDataDir, func() (ProcessingPhase, error) { + pp, err := dp.source.Transfer(dp.dataDir) + if err != nil { + err = errors.Wrap(err, "Unable to transfer source data to target directory") + } + return pp, err + }) + dp.RegisterPhaseExecutor(ProcessingPhaseTransferDataFile, func() (ProcessingPhase, error) { + pp, err := dp.source.TransferFile(dp.dataFile) + if err != nil { + err = errors.Wrap(err, "Unable to transfer source data to target file") + } + return pp, err + }) + dp.RegisterPhaseExecutor(ProcessingPhaseValidatePause, func() (ProcessingPhase, error) { + pp := ProcessingPhasePause + err := dp.validate(dp.source.GetURL()) + if err != nil { + pp = ProcessingPhaseError + } + return pp, err + }) + dp.RegisterPhaseExecutor(ProcessingPhaseConvert, func() (ProcessingPhase, error) { + pp, err := dp.convert(dp.source.GetURL()) + if err != nil { + err = errors.Wrap(err, "Unable to convert source data to target format") + } + return pp, err + }) + dp.RegisterPhaseExecutor(ProcessingPhaseResize, func() (ProcessingPhase, error) { + pp, err := dp.resize() + if err != nil { + err = errors.Wrap(err, "Unable to resize disk image to requested size") + } + return pp, err + }) + dp.RegisterPhaseExecutor(ProcessingPhaseMergeDelta, func() (ProcessingPhase, error) { + pp, err := dp.merge() + if err != nil { + err = errors.Wrap(err, "Unable to apply delta to base image") + } + return pp, err + }) +} + +// ProcessDataWithPause is the main processing loop. +func (dp *DataProcessor) ProcessDataWithPause() error { + visited := make(map[ProcessingPhase]bool, len(dp.phaseExecutors)) + for dp.currentPhase != ProcessingPhaseComplete && dp.currentPhase != ProcessingPhasePause { + if visited[dp.currentPhase] { + err := errors.Errorf("loop detected on phase %s", dp.currentPhase) + klog.Errorf("%+v", err) + return err + } + executor, ok := dp.phaseExecutors[dp.currentPhase] + if !ok { + return errors.Errorf("Unknown processing phase %s", dp.currentPhase) + } + nextPhase, err := executor() + visited[dp.currentPhase] = true + if err != nil { + klog.Errorf("%+v", err) + return err + } + dp.currentPhase = nextPhase + klog.V(1).Infof("New phase: %s\n", dp.currentPhase) + } + return nil +} + +func (dp *DataProcessor) validate(url *url.URL) error { + klog.V(1).Infoln("Validating image") + err := qemuOperations.Validate(url, dp.availableSpace) + if err != nil { + return ValidationSizeError{err: err} + } + return nil +} + +// convert is called when convert the image from the url to a RAW disk image. Source formats include RAW/QCOW2 (Raw to raw conversion is a copy) +func (dp *DataProcessor) convert(url *url.URL) (ProcessingPhase, error) { + err := dp.validate(url) + if err != nil { + return ProcessingPhaseError, err + } + err = CleanAll(dp.dataFile) + if err != nil { + return ProcessingPhaseError, err + } + + format, err := util.GetFormat(dp.dataFile) + if err != nil { + return ProcessingPhaseError, errors.Wrap(err, "Unable to get format") + } + klog.V(3).Infof("Converting to %s", format) + err = qemuOperations.ConvertToFormatStream(url, format, dp.dataFile, dp.preallocation) + if err != nil { + return ProcessingPhaseError, errors.Wrapf(err, "Conversion to %s failed", format) + } + dp.preallocationApplied = dp.preallocation + + return ProcessingPhaseResize, nil +} + +func (dp *DataProcessor) resize() (ProcessingPhase, error) { + size, _ := getAvailableSpaceBlockFunc(dp.dataFile) + klog.V(3).Infof("Available space in dataFile: %d", size) + isBlockDev := size >= int64(0) + if !isBlockDev { + if dp.requestImageSize != "" { + klog.V(3).Infoln("Resizing image") + err := ResizeImage(dp.dataFile, dp.requestImageSize, dp.getUsableSpace(), dp.preallocation) + if err != nil { + return ProcessingPhaseError, errors.Wrap(err, "Resize of image failed") + } + } + // Validate that a sparse file will fit even as it fills out. + dataFileURL, err := url.Parse(dp.dataFile) + if err != nil { + return ProcessingPhaseError, err + } + err = dp.validate(dataFileURL) + if err != nil { + return ProcessingPhaseError, err + } + } + dp.preallocationApplied = dp.preallocation + if dp.dataFile != "" && !isBlockDev { + // Change permissions to 0660 + err := os.Chmod(dp.dataFile, 0o660) + if err != nil { + return ProcessingPhaseError, errors.Wrap(err, "Unable to change permissions of target file") + } + } + + return ProcessingPhaseComplete, nil +} + +// ResizeImage resizes the images to match the requested size. Sometimes provisioners misbehave and the available space +// is not the same as the requested space. For those situations we compare the available space to the requested space and +// use the smallest of the two values. +func ResizeImage(dataFile, imageSize string, totalTargetSpace int64, preallocation bool) error { + dataFileURL, _ := url.Parse(dataFile) + info, err := qemuOperations.Info(dataFileURL) + if err != nil { + return err + } + if imageSize != "" { + currentImageSizeQuantity := resource.NewScaledQuantity(info.VirtualSize, 0) + newImageSizeQuantity := resource.MustParse(imageSize) + minSizeQuantity := util.MinQuantity(resource.NewScaledQuantity(totalTargetSpace, 0), &newImageSizeQuantity) + if minSizeQuantity.Cmp(newImageSizeQuantity) != 0 { + // Available destination space is smaller than the size we want to resize to + klog.Warningf("Available space less than requested size, resizing image to available space %s.\n", minSizeQuantity.String()) + } + if currentImageSizeQuantity.Cmp(minSizeQuantity) == 0 { + klog.V(1).Infof("No need to resize image. Requested size: %s, Image size: %d.\n", imageSize, info.VirtualSize) + return nil + } + // Check if calculated size is < imageSize, and return error if so. + if currentImageSizeQuantity.Cmp(minSizeQuantity) == 1 { + klog.V(1).Infof("Calculated new size is < than current size, not resizing: requested size %s, virtual size: %d.\n", minSizeQuantity.String(), info.VirtualSize) + return nil + } + klog.V(1).Infof("Expanding image size to: %s\n", minSizeQuantity.String()) + return qemuOperations.Resize(dataFile, minSizeQuantity, preallocation) + } + return errors.New("Image resize called with blank resize") +} + +func (dp *DataProcessor) calculateTargetSize() int64 { + klog.V(1).Infof("Calculating available size\n") + var targetQuantity *resource.Quantity + size, err := getAvailableSpaceBlockFunc(dp.dataFile) + if err != nil { + klog.Error(err) + } + if size >= int64(0) { + // Block volume. + klog.V(1).Infof("Checking out block volume size.\n") + targetQuantity = resource.NewScaledQuantity(size, 0) + } else { + // File system volume. + klog.V(1).Infof("Checking out file system volume size.\n") + size, err := getAvailableSpaceFunc(dp.dataDir) + if err != nil { + klog.Error(err) + } + targetQuantity = resource.NewScaledQuantity(size, 0) + } + if dp.requestImageSize != "" { + klog.V(1).Infof("Request image size not empty.\n") + newImageSizeQuantity := resource.MustParse(dp.requestImageSize) + minQuantity := util.MinQuantity(targetQuantity, &newImageSizeQuantity) + targetQuantity = &minQuantity + } + klog.V(1).Infof("Target size %s.\n", targetQuantity.String()) + targetSize := targetQuantity.Value() + return targetSize +} + +// PreallocationApplied returns true if data processing path included preallocation step +func (dp *DataProcessor) PreallocationApplied() bool { + return dp.preallocationApplied +} + +func (dp *DataProcessor) getUsableSpace() int64 { + return util.GetUsableSpace(dp.filesystemOverhead, dp.availableSpace) +} + +// Rebase and commit a delta image to its backing file +func (dp *DataProcessor) merge() (ProcessingPhase, error) { + klog.V(1).Info("Merging QCOW to base image.") + imageURL := dp.source.GetURL() + if imageURL == nil { + return ProcessingPhaseError, errors.New("bad URL in data source") + } + if err := qemuOperations.Rebase(dp.dataFile, imageURL.String()); err != nil { + return ProcessingPhaseError, errors.Wrap(err, "error rebasing image") + } + if err := qemuOperations.Commit(imageURL.String()); err != nil { + return ProcessingPhaseError, errors.Wrap(err, "error committing image") + } + return ProcessingPhaseComplete, nil +} diff --git a/images/cdi-artifact/pkg/importer/errors.go b/images/cdi-artifact/pkg/importer/errors.go new file mode 100644 index 0000000000..506bd6b3ac --- /dev/null +++ b/images/cdi-artifact/pkg/importer/errors.go @@ -0,0 +1,42 @@ +package importer + +import ( + "fmt" + + "kubevirt.io/containerized-data-importer/pkg/common" +) + +// ValidationSizeError is an error indication size validation failure. +type ValidationSizeError struct { + err error +} + +func (e ValidationSizeError) Error() string { return e.err.Error() } + +// ErrRequiresScratchSpace indicates that we require scratch space. +var ErrRequiresScratchSpace = fmt.Errorf(common.ScratchSpaceRequired) + +// ErrInvalidPath indicates that the path is invalid. +var ErrInvalidPath = fmt.Errorf("invalid transfer path") + +// ImagePullFailedError indicates that the importer failed to pull an image; This error type wraps the actual error. +type ImagePullFailedError struct { + err error +} + +// NewImagePullFailedError creates new ImagePullFailedError error object, with embedded error. +// +// Use the err parameter fot the actual wrapped error +func NewImagePullFailedError(err error) *ImagePullFailedError { + return &ImagePullFailedError{ + err: err, + } +} + +func (err *ImagePullFailedError) Error() string { + return fmt.Sprintf("%s: %s", common.ImagePullFailureText, err.err.Error()) +} + +func (err *ImagePullFailedError) Unwrap() error { + return err.err +} diff --git a/images/cdi-artifact/pkg/importer/format-readers.go b/images/cdi-artifact/pkg/importer/format-readers.go new file mode 100644 index 0000000000..04c7e6dcd2 --- /dev/null +++ b/images/cdi-artifact/pkg/importer/format-readers.go @@ -0,0 +1,288 @@ +/* +Copyright 2018 The CDI Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package importer + +import ( + "bytes" + "compress/gzip" + "encoding/hex" + "io" + "strconv" + + "github.com/klauspost/compress/zstd" + "github.com/pkg/errors" + "github.com/ulikunitz/xz" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" + "kubevirt.io/containerized-data-importer/pkg/image" + metrics "kubevirt.io/containerized-data-importer/pkg/monitoring/metrics/pvc-importer" + "kubevirt.io/containerized-data-importer/pkg/util" + prometheusutil "kubevirt.io/containerized-data-importer/pkg/util/prometheus" +) + +var ownerUID string + +func init() { + if err := metrics.SetupMetrics(); err != nil { + klog.Errorf("Unable to create prometheus progress counter: %v", err) + } + ownerUID, _ = util.ParseEnvVar(common.OwnerUID, false) +} + +type reader struct { + rdrType int + rdr io.ReadCloser +} + +// FormatReaders contains the stack of readers needed to get information from the input stream (io.ReadCloser) +type FormatReaders struct { + readers []reader + buf []byte // holds file headers + Convert bool + Archived bool + ArchiveXz bool + ArchiveGz bool + ArchiveZstd bool + progressReader *prometheusutil.ProgressReader +} + +const ( + rdrGz = iota + rdrMulti + rdrXz + rdrStream +) + +// map scheme and format to rdrType +var rdrTypM = map[string]int{ + "gz": rdrGz, + "xz": rdrXz, + "stream": rdrStream, +} + +// NewFormatReaders creates a new instance of FormatReaders using the input stream and content type passed in. +func NewFormatReaders(stream io.ReadCloser, total uint64) (*FormatReaders, error) { + var err error + readers := &FormatReaders{ + buf: make([]byte, image.MaxExpectedHdrSize), + } + if total > uint64(0) { + readers.progressReader = prometheusutil.NewProgressReader(stream, metrics.Progress(ownerUID), total) + err = readers.constructReaders(readers.progressReader) + } else { + err = readers.constructReaders(stream) + } + return readers, err +} + +func (fr *FormatReaders) constructReaders(r io.ReadCloser) error { + fr.appendReader(rdrTypM["stream"], r) + knownHdrs := image.CopyKnownHdrs() // need local copy since keys are removed + klog.V(3).Infof("constructReaders: checking compression and archive formats\n") + for { + hdr, err := fr.matchHeader(&knownHdrs) + if err != nil { + return errors.WithMessage(err, "could not process image header") + } + if hdr == nil { + break // done processing headers, we have the orig source file + } + klog.V(2).Infof("found header of type %q\n", hdr.Format) + // create format-specific reader and append it to dataStream readers stack + fr.fileFormatSelector(hdr) + // exit loop if hdr is qcow2 + if hdr.Format == "qcow2" { + break + } + } + + return nil +} + +// Append to the receiver's reader stack the passed in reader. If the reader type is multi-reader +// then wrap a multi-reader around the passed in reader. If the reader is not a Closer then wrap a +// nop closer. +func (fr *FormatReaders) appendReader(rType int, x interface{}) { + if x == nil { + return + } + r, ok := x.(io.Reader) + if !ok { + klog.Errorf("internal error: unexpected reader type passed to appendReader()") + return + } + if rType == rdrMulti { + r = io.MultiReader(r, fr.TopReader()) + } + if _, ok := r.(io.Closer); !ok { + r = io.NopCloser(r) + } + fr.readers = append(fr.readers, reader{rdrType: rType, rdr: r.(io.ReadCloser)}) +} + +// TopReader return the top-level io.ReadCloser from the receiver Reader "stack". +func (fr *FormatReaders) TopReader() io.ReadCloser { + return fr.readers[len(fr.readers)-1].rdr +} + +// Based on the passed in header, append the format-specific reader to the readers stack, +// and update the receiver Size field. Note: a bool is set in the receiver for qcow2 files. +func (fr *FormatReaders) fileFormatSelector(hdr *image.Header) { + var r io.Reader + var err error + fFmt := hdr.Format + switch fFmt { + case "gz": + r, err = fr.gzReader() + if err == nil { + fr.Archived = true + fr.ArchiveGz = true + } + case "zst": + r, err = fr.zstReader() + if err == nil { + fr.Archived = true + fr.ArchiveZstd = true + } + case "xz": + r, err = fr.xzReader() + if err == nil { + fr.Archived = true + fr.ArchiveXz = true + } + case "qcow2": + r, err = fr.qcow2NopReader(hdr) + fr.Convert = true + case "vmdk": + r = nil + fr.Convert = true + case "vdi": + r = nil + fr.Convert = true + case "vhd": + r = nil + fr.Convert = true + case "vhdx": + r = nil + fr.Convert = true + } + if err == nil && r != nil { + fr.appendReader(rdrTypM[fFmt], r) + } +} + +// Return the gz reader and the size of the endpoint "through the eye" of the previous reader. +// Assumes a single file was gzipped. +// NOTE: size in gz is stored in the last 4 bytes of the file. This probably requires the file +// +// to be decompressed in order to get its original size. For now 0 is returned. +// +// TODO: support gz size. +func (fr *FormatReaders) gzReader() (io.ReadCloser, error) { + gz, err := gzip.NewReader(fr.TopReader()) + if err != nil { + return nil, errors.Wrap(err, "could not create gzip reader") + } + klog.V(2).Infof("gzip: extracting %q\n", gz.Name) + return gz, nil +} + +// Return the zst reader. +func (fr *FormatReaders) zstReader() (io.ReadCloser, error) { + zst, err := zstd.NewReader(fr.TopReader()) + if err != nil { + return nil, errors.Wrap(err, "could not create zst reader") + } + return zst.IOReadCloser(), nil +} + +// Return the size of the endpoint "through the eye" of the previous reader. Note: there is no +// qcow2 reader so nil is returned so that nothing is appended to the reader stack. +// Note: size is stored at offset 24 in the qcow2 header. +func (fr *FormatReaders) qcow2NopReader(h *image.Header) (io.Reader, error) { //nolint:unparam // upstream CDI API keeps the reader return for symmetry with other format handlers. + s := hex.EncodeToString(fr.buf[h.SizeOff : h.SizeOff+h.SizeLen]) + _, err := strconv.ParseInt(s, 16, 64) + if err != nil { + return nil, errors.Wrapf(err, "unable to determine original qcow2 file size from %+v", s) + } + return nil, nil +} + +// Return the xz reader and size of the endpoint "through the eye" of the previous reader. +// Assumes a single file was compressed. Note: the xz reader is not a closer so we wrap a +// nop Closer around it. +// NOTE: size is not stored in the xz header. This may require the file to be decompressed in +// +// order to get its original size. For now 0 is returned. +// +// TODO: support gz size. +func (fr *FormatReaders) xzReader() (io.Reader, error) { + xz, err := xz.NewReader(fr.TopReader()) + if err != nil { + return nil, errors.Wrap(err, "could not create xz reader") + } + return xz, nil +} + +// Return the matching header, if one is found, from the passed-in map of known headers. After a +// successful read append a multi-reader to the receiver's reader stack. +// Note: .iso files are not detected here but rather in the Size() function. +// Note: knownHdrs is passed by reference and modified. +func (fr *FormatReaders) matchHeader(knownHdrs *image.Headers) (*image.Header, error) { + _, err := fr.read(fr.buf) // read current header + if err != nil { + return nil, err + } + // append multi-reader so that the header data can be re-read by subsequent readers + fr.appendReader(rdrMulti, bytes.NewReader(fr.buf)) + + // loop through known headers until a match + for format, kh := range *knownHdrs { + if kh.Match(fr.buf) { + // delete this header format key so that it's not processed again + delete(*knownHdrs, format) + return &kh, nil + } + } + return nil, nil // no match +} + +// Read from top-most reader. Note: ReadFull is needed since there may be intermediate, +// smaller multi-readers in the reader stack, and we need to be able to fill buf. +func (fr *FormatReaders) read(buf []byte) (int, error) { + return io.ReadFull(fr.TopReader(), buf) +} + +// Close Readers in reverse order. +func (fr *FormatReaders) Close() (rtnerr error) { + var err error + for i := len(fr.readers) - 1; i >= 0; i-- { + err = fr.readers[i].rdr.Close() + if err != nil { + rtnerr = err // tracking last error + } + } + return rtnerr +} + +// StartProgressUpdate starts the go routine to automatically update the progress on a set interval. +func (fr *FormatReaders) StartProgressUpdate() { + if fr.progressReader != nil { + fr.progressReader.StartTimedUpdate() + } +} diff --git a/images/cdi-artifact/pkg/importer/nbd_wait.go b/images/cdi-artifact/pkg/importer/nbd_wait.go new file mode 100644 index 0000000000..178dc87614 --- /dev/null +++ b/images/cdi-artifact/pkg/importer/nbd_wait.go @@ -0,0 +1,37 @@ +package importer + +import ( + "fmt" + "net" + "net/url" + "time" +) + +const nbdDialInterval = time.Second + +// WaitForNBDEndpoint blocks until the NBD TCP endpoint accepts connections or timeout expires. +func WaitForNBDEndpoint(nbdEndpoint string, timeout time.Duration) error { + parsed, err := url.Parse(nbdEndpoint) + if err != nil { + return fmt.Errorf("parse NBD endpoint %q: %w", nbdEndpoint, err) + } + if parsed.Scheme != "nbd" { + return fmt.Errorf("unsupported NBD endpoint scheme %q", parsed.Scheme) + } + if parsed.Host == "" { + return fmt.Errorf("NBD endpoint %q has empty host", nbdEndpoint) + } + + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", parsed.Host, 2*time.Second) + if err == nil { + _ = conn.Close() + return nil + } + lastErr = err + time.Sleep(nbdDialInterval) + } + return fmt.Errorf("timed out waiting for NBD endpoint %q: %w", nbdEndpoint, lastErr) +} diff --git a/images/cdi-artifact/pkg/importer/nbd_wait_test.go b/images/cdi-artifact/pkg/importer/nbd_wait_test.go new file mode 100644 index 0000000000..f683b52da3 --- /dev/null +++ b/images/cdi-artifact/pkg/importer/nbd_wait_test.go @@ -0,0 +1,65 @@ +package importer + +import ( + "fmt" + "net" + "testing" + "time" +) + +func TestWaitForNBDEndpointRejectsInvalidURL(t *testing.T) { + t.Parallel() + + err := WaitForNBDEndpoint("://bad-url", time.Second) + if err == nil { + t.Fatal("expected parse error") + } +} + +func TestWaitForNBDEndpointRejectsUnsupportedScheme(t *testing.T) { + t.Parallel() + + err := WaitForNBDEndpoint("http://127.0.0.1:1", time.Second) + if err == nil { + t.Fatal("expected scheme error") + } +} + +func TestWaitForNBDEndpointRejectsEmptyHost(t *testing.T) { + t.Parallel() + + err := WaitForNBDEndpoint("nbd://", time.Second) + if err == nil { + t.Fatal("expected empty host error") + } +} + +func TestWaitForNBDEndpointSucceedsWhenListenerIsReady(t *testing.T) { + t.Parallel() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + port := ln.Addr().(*net.TCPAddr).Port + endpoint := fmt.Sprintf("nbd://127.0.0.1:%d", port) + + if err := WaitForNBDEndpoint(endpoint, 2*time.Second); err != nil { + t.Fatalf("WaitForNBDEndpoint: %v", err) + } +} + +func TestWaitForNBDEndpointTimesOutWhenNothingListens(t *testing.T) { + t.Parallel() + + start := time.Now() + err := WaitForNBDEndpoint("nbd://127.0.0.1:1", 1500*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error") + } + if elapsed := time.Since(start); elapsed < 1500*time.Millisecond { + t.Fatalf("expected to wait at least 1.5s, got %v", elapsed) + } +} diff --git a/images/cdi-artifact/pkg/importer/nbdcopy.go b/images/cdi-artifact/pkg/importer/nbdcopy.go new file mode 100644 index 0000000000..9e091ec2fa --- /dev/null +++ b/images/cdi-artifact/pkg/importer/nbdcopy.go @@ -0,0 +1,106 @@ +package importer + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "sync" + + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" + metrics "kubevirt.io/containerized-data-importer/pkg/monitoring/metrics/pvc-importer" + "kubevirt.io/containerized-data-importer/pkg/util" +) + +const nbdcopyProgressFD = 3 + +var nbdcopyBinary = "nbdcopy" + +// CopyNBDToDevice copies an NBD export byte-for-byte to dest using nbdcopy. +// Progress is published to kubevirt_cdi_import_progress_total when OwnerUID is set. +func CopyNBDToDevice(nbdURL, dest string) error { + ownerUID, _ := util.ParseEnvVar(common.OwnerUID, false) + + progressR, progressW, err := os.Pipe() + if err != nil { + return fmt.Errorf("create nbdcopy progress pipe: %w", err) + } + + cmd := exec.CommandContext(context.Background(), nbdcopyBinary, + fmt.Sprintf("--progress=%d", nbdcopyProgressFD), + "--flush", + "--allocated", + nbdURL, + dest, + ) + cmd.Stdout = os.Stdout + var stderr bytes.Buffer + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + cmd.ExtraFiles = []*os.File{progressW} + + if err := cmd.Start(); err != nil { + _ = progressR.Close() + _ = progressW.Close() + return fmt.Errorf("start nbdcopy: %w", err) + } + _ = progressW.Close() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + scanner := bufio.NewScanner(progressR) + for scanner.Scan() { + reportNbdcopyProgress(scanner.Text(), ownerUID) + } + if scanErr := scanner.Err(); scanErr != nil { + klog.V(3).Infof("nbdcopy progress reader stopped: %v", scanErr) + } + }() + + waitErr := cmd.Wait() + wg.Wait() + _ = progressR.Close() + + if waitErr != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("nbdcopy failed: %w: %s", waitErr, msg) + } + return fmt.Errorf("nbdcopy failed: %w", waitErr) + } + + if ownerUID != "" { + reportNbdcopyProgress("100/100", ownerUID) + } + return nil +} + +func reportNbdcopyProgress(line, ownerUID string) { + if ownerUID == "" { + return + } + + line = strings.TrimSpace(line) + parts := strings.Split(line, "/") + if len(parts) != 2 || parts[1] != "100" { + return + } + + value, err := strconv.ParseFloat(parts[0], 64) + if err != nil || value <= 0 { + return + } + + progress, err := metrics.Progress(ownerUID).Get() + if err == nil && value > progress { + metrics.Progress(ownerUID).Add(value - progress) + } +} diff --git a/images/cdi-artifact/pkg/importer/nbdcopy_test.go b/images/cdi-artifact/pkg/importer/nbdcopy_test.go new file mode 100644 index 0000000000..49c85e8a79 --- /dev/null +++ b/images/cdi-artifact/pkg/importer/nbdcopy_test.go @@ -0,0 +1,45 @@ +package importer + +import ( + "testing" + + metrics "kubevirt.io/containerized-data-importer/pkg/monitoring/metrics/pvc-importer" +) + +func TestReportNbdcopyProgressUpdatesImportMetric(t *testing.T) { + uid := "nbdcopy-progress" + metrics.Progress(uid).Delete() + + reportNbdcopyProgress("0/100", uid) + if got, err := metrics.Progress(uid).Get(); err != nil || got != 0 { + t.Fatalf("expected 0 for zero progress, got %v err=%v", got, err) + } + + reportNbdcopyProgress("25/100", uid) + if got, err := metrics.Progress(uid).Get(); err != nil || got != 25 { + t.Fatalf("expected 25, got %v err=%v", got, err) + } + + reportNbdcopyProgress("10/100", uid) + if got, err := metrics.Progress(uid).Get(); err != nil || got != 25 { + t.Fatalf("expected progress to stay monotonic at 25, got %v err=%v", got, err) + } + + reportNbdcopyProgress("100/100", uid) + if got, err := metrics.Progress(uid).Get(); err != nil || got != 100 { + t.Fatalf("expected 100, got %v err=%v", got, err) + } +} + +func TestReportNbdcopyProgressIgnoresInvalidLines(t *testing.T) { + uid := "nbdcopy-progress-invalid" + metrics.Progress(uid).Delete() + + reportNbdcopyProgress("(50.00/100%)", uid) + reportNbdcopyProgress("50/100%", uid) + reportNbdcopyProgress("", uid) + + if got, err := metrics.Progress(uid).Get(); err != nil || got != 0 { + t.Fatalf("expected 0 for invalid lines, got %v err=%v", got, err) + } +} diff --git a/images/cdi-artifact/pkg/importer/registry-datasource.go b/images/cdi-artifact/pkg/importer/registry-datasource.go new file mode 100644 index 0000000000..e622fb119e --- /dev/null +++ b/images/cdi-artifact/pkg/importer/registry-datasource.go @@ -0,0 +1,218 @@ +/* +Copyright 2018 The CDI Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package importer + +import ( + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/containers/image/v5/types" + "github.com/pkg/errors" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" + "kubevirt.io/containerized-data-importer/pkg/util" +) + +const ( + // containerDiskImageDir - Expected disk image location in container image as described in + // https://github.com/kubevirt/kubevirt/blob/main/docs/container-register-disks.md + containerDiskImageDir = "disk" +) + +// RegistryDataSource is the struct containing the information needed to import from a registry data source. +// Sequence of phases: +// 1. Info -> Transfer +// 2. Transfer -> Convert +type RegistryDataSource struct { + endpoint string + accessKey string + secKey string + certDir string + insecureTLS bool + imageDir string + // The discovered image file in scratch space. + url *url.URL + // The discovered image info from the registry. + info *types.ImageInspectInfo +} + +// NewRegistryDataSource creates a new instance of the Registry Data Source. +func NewRegistryDataSource(endpoint, accessKey, secKey, certDir string, insecureTLS bool) *RegistryDataSource { + allCertDir, err := CreateCertificateDir(certDir) + if err != nil { + klog.Infof("Error creating allCertDir %v", err) + if allCertDir != "/" { + err = os.RemoveAll(allCertDir) + if err != nil { + klog.Errorf("Unable to clean up all cert dir %v", err) + } + } + allCertDir = certDir + } + return &RegistryDataSource{ + endpoint: endpoint, + accessKey: accessKey, + secKey: secKey, + certDir: allCertDir, + insecureTLS: insecureTLS, + } +} + +// Info is called to get initial information about the data. No information available for registry currently. +func (rd *RegistryDataSource) Info() (ProcessingPhase, error) { + return ProcessingPhaseTransferScratch, nil +} + +// Transfer is called to transfer the data from the source registry to a temporary location. +func (rd *RegistryDataSource) Transfer(path string) (ProcessingPhase, error) { + rd.imageDir = filepath.Join(path, containerDiskImageDir) + if err := CleanAll(rd.imageDir); err != nil { + return ProcessingPhaseError, err + } + + size, err := util.GetAvailableSpace(path) + if err != nil { + return ProcessingPhaseError, err + } + if size <= int64(0) { + // Path provided is invalid. + return ProcessingPhaseError, ErrInvalidPath + } + + klog.V(1).Infof("Copying registry image to scratch space.") + rd.info, err = CopyRegistryImage(rd.endpoint, path, containerDiskImageDir, rd.accessKey, rd.secKey, rd.certDir, rd.insecureTLS) + if err != nil { + return ProcessingPhaseError, errors.Wrapf(err, "Failed to read registry image") + } + + imageFile, err := getImageFileName(rd.imageDir) + if err != nil { + return ProcessingPhaseError, errors.Wrapf(err, "Cannot locate image file") + } + + // imageFile and rd.imageDir are both valid, thus the Join will be valid, and the parse will work, no need to check for parse errors + rd.url, _ = url.Parse(filepath.Join(rd.imageDir, imageFile)) + klog.V(3).Infof("Successfully found file. VM disk image filename is %s", rd.url.String()) + return ProcessingPhaseConvert, nil +} + +// TransferFile is called to transfer the data from the source to the passed in file. +func (rd *RegistryDataSource) TransferFile(fileName string) (ProcessingPhase, error) { + return ProcessingPhaseError, errors.New("Transferfile should not be called") +} + +// GetURL returns the url that the data processor can use when converting the data. +func (rd *RegistryDataSource) GetURL() *url.URL { + return rd.url +} + +// GetTerminationMessage returns data to be serialized and used as the termination message of the importer. +func (rd *RegistryDataSource) GetTerminationMessage() *common.TerminationMessage { + if rd.info == nil { + return nil + } + return &common.TerminationMessage{ + Labels: envsToLabels(rd.info.Env), + } +} + +// Close closes any readers or other open resources. +func (rd *RegistryDataSource) Close() error { + // No-op, no open readers + return nil +} + +func getImageFileName(dir string) (string, error) { + if _, err := os.Stat(dir); os.IsNotExist(err) { + klog.Errorf("image directory does not exist") + return "", errors.Errorf("image directory does not exist") + } + + entries, err := os.ReadDir(dir) + if err != nil { + klog.Errorf("Error reading directory") + return "", errors.Wrapf(err, "image file does not exist in image directory") + } + + if len(entries) == 0 { + klog.Errorf("image file does not exist in image directory - directory is empty ") + return "", errors.New("image file does not exist in image directory - directory is empty") + } + + if len(entries) > 1 { + klog.Errorf("image directory contains more than one file") + return "", errors.New("image directory contains more than one file") + } + + fileinfo := entries[0] + if fileinfo.IsDir() { + klog.Errorf("image file does not exist in image directory contains another directory ") + return "", errors.New("image directory contains another directory") + } + + filename := fileinfo.Name() + + if len(strings.TrimSpace(filename)) == 0 { + klog.Errorf("image file does not exist in image directory - file has no name ") + return "", errors.New("image file does has no name") + } + + klog.V(1).Infof("VM disk image filename is %s", filename) + + return filename, nil +} + +// CreateCertificateDir creates a common certificate dir +func CreateCertificateDir(registryCertDir string) (string, error) { + allCerts := "/tmp/all_certs" + if err := os.MkdirAll(allCerts, 0o700); err != nil { + return allCerts, err + } + + klog.Info("Copying proxy certs") + if err := collectCerts(common.ImporterProxyCertDir, allCerts, "proxy-"); err != nil { + return allCerts, err + } + klog.Info("Copying registry certs") + if err := collectCerts(registryCertDir, allCerts, ""); err != nil { + return allCerts, err + } + return allCerts, nil +} + +func collectCerts(certDir, targetDir, targetPrefix string) error { + directory, err := os.Open(certDir) + if err != nil { + return err + } + objects, err := directory.Readdir(-1) + if err != nil { + return err + } + for _, obj := range objects { + if !strings.HasSuffix(obj.Name(), ".crt") { + continue + } + if err := util.LinkFile(filepath.Join(certDir, obj.Name()), filepath.Join(targetDir, targetPrefix+obj.Name())); err != nil { + return err + } + } + return nil +} diff --git a/images/cdi-artifact/pkg/importer/transport.go b/images/cdi-artifact/pkg/importer/transport.go new file mode 100644 index 0000000000..3c52bc9bd4 --- /dev/null +++ b/images/cdi-artifact/pkg/importer/transport.go @@ -0,0 +1,315 @@ +/* +Copyright 2020 The CDI Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package importer + +import ( + "archive/tar" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/containers/image/v5/docker" + "github.com/containers/image/v5/image" + "github.com/containers/image/v5/oci/archive" + "github.com/containers/image/v5/pkg/blobinfocache" + "github.com/containers/image/v5/types" + "github.com/pkg/errors" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" + metrics "kubevirt.io/containerized-data-importer/pkg/monitoring/metrics/pvc-importer" + "kubevirt.io/containerized-data-importer/pkg/util" + prometheusutil "kubevirt.io/containerized-data-importer/pkg/util/prometheus" +) + +const ( + whFilePrefix = ".wh." + + registrySchemeDocker = "docker" + registrySchemeOCI = "oci-archive" + + // transferScratchMaxProgress is the upper bound of the TransferScratch + // phase contribution to the overall import progress (0..100). The remaining + // half (50..100) is occupied by the Convert phase, see qemu.reportProgress. + transferScratchMaxProgress = 49.0 +) + +// scaledProgressMetric adapts a 0..100 prometheus.ProgressMetric so that the +// underlying counter advances only within the [low, high] sub-range. It is +// used to project the per-layer download progress reported by +// prometheusutil.ProgressReader (which always works in 0..100) into the +// 0..transferScratchMaxProgress slice of the shared import_progress counter. +type scaledProgressMetric struct { + base prometheusutil.ProgressMetric + low, high float64 +} + +func (s scaledProgressMetric) Get() (float64, error) { + cur, err := s.base.Get() + if err != nil { + return 0, err + } + if cur <= s.low { + return 0, nil + } + if cur >= s.high { + return 100, nil + } + return (cur - s.low) * 100.0 / (s.high - s.low), nil +} + +func (s scaledProgressMetric) Add(delta float64) { + if delta <= 0 { + return + } + s.base.Add(delta * (s.high - s.low) / 100.0) +} + +func (s scaledProgressMetric) Delete() { s.base.Delete() } + +// transferProgressMetric returns a 0..transferScratchMaxProgress projection of +// the shared import_progress counter that ProgressReader can drive in 0..100. +func transferProgressMetric() prometheusutil.ProgressMetric { + ownerUID, _ := util.ParseEnvVar(common.OwnerUID, false) + return scaledProgressMetric{ + base: metrics.Progress(ownerUID), + low: 0, + high: transferScratchMaxProgress, + } +} + +func commandTimeoutContext() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) +} + +func buildSourceContext(accessKey, secKey, certDir string, insecureRegistry bool) *types.SystemContext { + ctx := &types.SystemContext{} + if accessKey != "" && secKey != "" { + ctx.DockerAuthConfig = &types.DockerAuthConfig{ + Username: accessKey, + Password: secKey, + } + } + if certDir != "" { + ctx.DockerCertPath = certDir + ctx.DockerDaemonCertPath = certDir + } + + if insecureRegistry { + ctx.DockerDaemonInsecureSkipTLSVerify = true + ctx.DockerInsecureSkipTLSVerify = types.NewOptionalBool(true) + } + + return ctx +} + +func readImageSource(ctx context.Context, sys *types.SystemContext, img string) (types.ImageSource, error) { + ref, err := parseImageName(img) + if err != nil { + klog.Errorf("Could not parse image: %v", err) + return nil, errors.Wrap(err, "Could not parse image") + } + + src, err := ref.NewImageSource(ctx, sys) + if err != nil { + klog.Errorf("Could not create image reference: %v", err) + return nil, NewImagePullFailedError(err) + } + + return src, nil +} + +func parseImageName(img string) (types.ImageReference, error) { + parts := strings.SplitN(img, ":", 2) + if len(parts) != 2 { + return nil, errors.Errorf(`Invalid image name "%s", expected colon-separated transport:reference`, img) + } + switch parts[0] { + case registrySchemeDocker: + return docker.ParseReference(parts[1]) + case registrySchemeOCI: + return archive.ParseReference(parts[1]) + } + return nil, errors.Errorf(`Invalid image name "%s", unknown transport`, img) +} + +func closeImage(src types.ImageSource) { + if err := src.Close(); err != nil { + klog.Warningf("Could not close image source: %v ", err) + } +} + +func hasPrefix(path, pathPrefix string) bool { + return strings.HasPrefix(path, pathPrefix) || + strings.HasPrefix(path, "./"+pathPrefix) +} + +func isWhiteout(path string) bool { + return strings.HasPrefix(filepath.Base(path), whFilePrefix) +} + +func isDir(hdr *tar.Header) bool { + return hdr.Typeflag == tar.TypeDir +} + +func processLayer(ctx context.Context, + _ *types.SystemContext, + src types.ImageSource, + layer types.BlobInfo, + destDir string, + pathPrefix string, + cache types.BlobInfoCache, + stopAtFirst bool, +) (bool, error) { + var reader io.ReadCloser + reader, _, err := src.GetBlob(ctx, layer, cache) + if err != nil { + klog.Errorf("Could not read layer: %v", err) + return false, errors.Wrap(err, "Could not read layer") + } + // Track download progress of the current layer in the lower half of the + // shared import_progress counter (0..transferScratchMaxProgress). The + // Convert phase later fills 50..100. + if layer.Size > 0 { + progressReader := prometheusutil.NewProgressReader(reader, transferProgressMetric(), uint64(layer.Size)) + progressReader.StartTimedUpdate() + reader = progressReader + } + fr, err := NewFormatReaders(reader, 0) + if err != nil { + return false, errors.Wrap(err, "Could not read layer") + } + defer func() { _ = fr.Close() }() + + tarReader := tar.NewReader(fr.TopReader()) + found := false + for { + hdr, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break // End of archive + } + if err != nil { + klog.Errorf("Error reading layer: %v", err) + return false, errors.Wrap(err, "Error reading layer") + } + + if hasPrefix(hdr.Name, pathPrefix) && !isWhiteout(hdr.Name) && !isDir(hdr) { + klog.Infof("File '%v' found in the layer", hdr.Name) + destFile, err := safeJoinPaths(destDir, hdr.Name) + if err != nil { + klog.Errorf("Error sanitizing archive path: %v", err) + return false, errors.Wrap(err, "Error sanitizing archive path") + } + + if err = os.MkdirAll(filepath.Dir(destFile), os.ModePerm); err != nil { + klog.Errorf("Error creating output file's directory: %v", err) + return false, errors.Wrap(err, "Error creating output file's directory") + } + + if err := streamDataToFile(tarReader, destFile); err != nil { + klog.Errorf("Error copying file: %v", err) + return false, errors.Wrap(err, "Error copying file") + } + + found = true + if stopAtFirst { + return found, nil + } + } + } + + return found, nil +} + +// Sanitize archive file pathing from "G305: Zip Slip vulnerability" +// https://security.snyk.io/research/zip-slip-vulnerability +func safeJoinPaths(dir, path string) (v string, err error) { + v = filepath.Join(dir, path) + wantPrefix := filepath.Clean(dir) + string(os.PathSeparator) + + if strings.HasPrefix(v, wantPrefix) { + return v, nil + } + + return "", fmt.Errorf("%s: %s", "content filepath is tainted", path) +} + +func copyRegistryImage(url, destDir, pathPrefix, accessKey, secKey, certDir string, insecureRegistry, stopAtFirst bool) (*types.ImageInspectInfo, error) { + klog.Infof("Downloading image from '%v', copying file from '%v' to '%v'", url, pathPrefix, destDir) + + ctx, cancel := commandTimeoutContext() + defer cancel() + srcCtx := buildSourceContext(accessKey, secKey, certDir, insecureRegistry) + + src, err := readImageSource(ctx, srcCtx, url) + if err != nil { + return nil, err + } + defer closeImage(src) + + imgCloser, err := image.FromSource(ctx, srcCtx, src) + if err != nil { + klog.Errorf("Error retrieving image: %v", err) + return nil, errors.Wrap(err, "Error retrieving image") + } + defer func() { _ = imgCloser.Close() }() + + cache := blobinfocache.DefaultCache(srcCtx) + found := false + layers := imgCloser.LayerInfos() + + for _, layer := range layers { + klog.Infof("Processing layer %+v", layer) + + found, err = processLayer(ctx, srcCtx, src, layer, destDir, pathPrefix, cache, stopAtFirst) + if found { + break + } + if err != nil { + // Skipping layer and trying the next one. + // Error already logged in processLayer + continue + } + } + + if !found { + klog.Errorf("Failed to find VM disk image file in the container image") + return nil, errors.New("Failed to find VM disk image file in the container image") + } + + info, err := imgCloser.Inspect(ctx) + if err != nil { + return nil, err + } + + return info, nil +} + +// CopyRegistryImage download image from registry with docker image API. It will extract first file under the pathPrefix +// url: source registry url. +// destDir: the scratch space destination. +// pathPrefix: path to extract files from. +// accessKey: accessKey for the registry described in url. +// secKey: secretKey for the registry described in url. +// certDir: directory public CA keys are stored for registry identity verification +// insecureRegistry: boolean if true will allow insecure registries. +func CopyRegistryImage(url, destDir, pathPrefix, accessKey, secKey, certDir string, insecureRegistry bool) (*types.ImageInspectInfo, error) { + return copyRegistryImage(url, destDir, pathPrefix, accessKey, secKey, certDir, insecureRegistry, true) +} diff --git a/images/cdi-artifact/pkg/importer/util.go b/images/cdi-artifact/pkg/importer/util.go new file mode 100644 index 0000000000..2b565606c8 --- /dev/null +++ b/images/cdi-artifact/pkg/importer/util.go @@ -0,0 +1,103 @@ +package importer + +import ( + "io" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/pkg/errors" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/common" + "kubevirt.io/containerized-data-importer/pkg/util" +) + +const ( + kubevirtEnvPrefix = "KUBEVIRT_IO_" + kubevirtLabelPrefix = "kubevirt.io/" +) + +// ParseEndpoint parses the required endpoint and return the url struct. +func ParseEndpoint(endpt string) (*url.URL, error) { + if endpt == "" { + // Because we are passing false, we won't decode anything and there is no way to error. + endpt, _ = util.ParseEnvVar(common.ImporterEndpoint, false) + if endpt == "" { + return nil, errors.Errorf("endpoint %q is missing or blank", common.ImporterEndpoint) + } + } + return url.Parse(endpt) +} + +// CleanAll deletes all files at specified paths (recursively) +func CleanAll(paths ...string) error { + for _, p := range paths { + isDevice, err := util.IsDevice(p) + if err != nil { + return err + } + + if !isDevice { + // Remove handles p not existing + if err := os.RemoveAll(p); err != nil { + return err + } + } + } + return nil +} + +// GetTerminationChannel returns a channel that listens for SIGTERM +func GetTerminationChannel() <-chan os.Signal { + terminationChannel := make(chan os.Signal, 1) + signal.Notify(terminationChannel, os.Interrupt, syscall.SIGTERM) + return terminationChannel +} + +func envsToLabels(envs []string) map[string]string { + labels := map[string]string{} + for _, env := range envs { + k, v, found := strings.Cut(env, "=") + if !found || !strings.Contains(k, kubevirtEnvPrefix) { + continue + } + labels[envToLabel(k)] = v + } + + return labels +} + +func envToLabel(env string) string { + label := "" + before, after, _ := strings.Cut(env, kubevirtEnvPrefix) + if elems := strings.Split(strings.TrimSuffix(before, "_"), "_"); len(elems) > 0 && elems[0] != "" { + label += strings.Join(elems, ".") + "." + } + label += kubevirtLabelPrefix + label += strings.Join(strings.Split(after, "_"), "-") + + return strings.ToLower(label) +} + +// streamDataToFile provides a function to stream the specified io.Reader to the specified local file +func streamDataToFile(r io.Reader, fileName string) error { + outFile, err := util.OpenFileOrBlockDevice(fileName) + if err != nil { + return err + } + defer outFile.Close() + klog.V(1).Infof("Writing data...\n") + if _, err = io.Copy(outFile, r); err != nil { + klog.Errorf("Unable to write file from dataReader: %v\n", err) + _ = os.Remove(outFile.Name()) + if strings.Contains(err.Error(), "no space left on device") { + return errors.Wrapf(err, "unable to write to file") + } + return NewImagePullFailedError(err) + } + err = outFile.Sync() + return err +} diff --git a/images/cdi-artifact/pkg/monitoring/metrics/pvc-importer/import_metrics.go b/images/cdi-artifact/pkg/monitoring/metrics/pvc-importer/import_metrics.go new file mode 100644 index 0000000000..6264a75260 --- /dev/null +++ b/images/cdi-artifact/pkg/monitoring/metrics/pvc-importer/import_metrics.go @@ -0,0 +1,52 @@ +package pvcimporter + +import ( + "github.com/machadovilaca/operator-observability/pkg/operatormetrics" + ioprometheusclient "github.com/prometheus/client_model/go" +) + +const ( + // ImportProgressMetricName is the name of the import progress metric + ImportProgressMetricName = "kubevirt_cdi_import_progress_total" +) + +var ( + importerMetrics = []operatormetrics.Metric{ + importProgress, + } + + importProgress = operatormetrics.NewCounterVec( + operatormetrics.MetricOpts{ + Name: ImportProgressMetricName, + Help: "The import progress in percentage", + }, + []string{"ownerUID"}, + ) +) + +type ImportProgress struct { + ownerUID string +} + +func Progress(ownerUID string) *ImportProgress { + return &ImportProgress{ownerUID} +} + +// Add adds value to the importProgress metric +func (ip *ImportProgress) Add(value float64) { + importProgress.WithLabelValues(ip.ownerUID).Add(value) +} + +// Get returns the importProgress value +func (ip *ImportProgress) Get() (float64, error) { + dto := &ioprometheusclient.Metric{} + if err := importProgress.WithLabelValues(ip.ownerUID).Write(dto); err != nil { + return 0, err + } + return dto.Counter.GetValue(), nil +} + +// Delete removes the importProgress metric with the passed label +func (ip *ImportProgress) Delete() { + importProgress.DeleteLabelValues(ip.ownerUID) +} diff --git a/images/cdi-artifact/pkg/monitoring/metrics/pvc-importer/metrics.go b/images/cdi-artifact/pkg/monitoring/metrics/pvc-importer/metrics.go new file mode 100644 index 0000000000..e35a1a6273 --- /dev/null +++ b/images/cdi-artifact/pkg/monitoring/metrics/pvc-importer/metrics.go @@ -0,0 +1,12 @@ +package pvcimporter + +import ( + "github.com/machadovilaca/operator-observability/pkg/operatormetrics" +) + +// SetupMetrics register prometheus metrics +func SetupMetrics() error { + return operatormetrics.RegisterMetrics( + importerMetrics, + ) +} diff --git a/images/cdi-artifact/pkg/system/prlimit.go b/images/cdi-artifact/pkg/system/prlimit.go new file mode 100644 index 0000000000..e9b9e7a64e --- /dev/null +++ b/images/cdi-artifact/pkg/system/prlimit.go @@ -0,0 +1,194 @@ +/* +Copyright 2018 The CDI Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package system + +import ( + "bufio" + "bytes" + "context" + "os/exec" + "syscall" + "time" + "unsafe" + + "github.com/pkg/errors" + "golang.org/x/sys/unix" + "k8s.io/klog/v2" +) + +// ProcessLimiter defines the methods limiting resources of a Process +type ProcessLimiter interface { + SetAddressSpaceLimit(pid int, value uint64) error + SetCPUTimeLimit(pid int, value uint64) error +} + +// ProcessLimitValues specifies the resource limits available to a process +type ProcessLimitValues struct { + AddressSpaceLimit uint64 + CPUTimeLimit uint64 +} + +type processLimiter struct{} + +var ( + execCommand = exec.Command + execCommandContext = exec.CommandContext +) + +var limiter = NewProcessLimiter() + +// NewProcessLimiter returns a new ProcessLimiter +func NewProcessLimiter() ProcessLimiter { + return &processLimiter{} +} + +func (p *processLimiter) SetAddressSpaceLimit(pid int, value uint64) error { + return prlimit(pid, unix.RLIMIT_AS, &syscall.Rlimit{Cur: value, Max: value}) +} + +func (p *processLimiter) SetCPUTimeLimit(pid int, value uint64) error { + return prlimit(pid, unix.RLIMIT_CPU, &syscall.Rlimit{Cur: value, Max: value}) +} + +// SetAddressSpaceLimit sets a limit on total address space of a process +func SetAddressSpaceLimit(pid int, value uint64) error { + return limiter.SetAddressSpaceLimit(pid, value) +} + +// SetCPUTimeLimit sets a limit on the total cpu time a process may have +func SetCPUTimeLimit(pid int, value uint64) error { + return limiter.SetCPUTimeLimit(pid, value) +} + +// scanLinesWithCR is an alternate split function that works with carriage returns as well +// as new lines. +func scanLinesWithCR(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexByte(data, '\r'); i >= 0 { + // We have a full carriage return-terminated line. + return i + 1, data[0:i], nil + } + if i := bytes.IndexByte(data, '\n'); i >= 0 { + // We have a full newline-terminated line. + return i + 1, data[0:i], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} + +func processScanner(scanner *bufio.Scanner, buf *bytes.Buffer, done chan bool, callback func(string)) { + for scanner.Scan() { + line := scanner.Text() + buf.WriteString(line) + buf.WriteString("\n") + if callback != nil { + callback(line) + } + } + done <- true +} + +// ExecWithLimits executes a command with process limits +func ExecWithLimits(limits *ProcessLimitValues, callback func(string), command string, args ...string) ([]byte, error) { + return executeWithLimits(limits, callback, true, command, args...) +} + +// ExecWithLimitsSilently executes a command with process limits and do not print output on error +func ExecWithLimitsSilently(limits *ProcessLimitValues, callback func(string), command string, args ...string) ([]byte, error) { + return executeWithLimits(limits, callback, false, command, args...) +} + +func executeWithLimits(limits *ProcessLimitValues, callback func(string), logErr bool, command string, args ...string) ([]byte, error) { + // Args can potentially contain sensitive information, make sure NOT to write args to the logs. + var buf, errBuf bytes.Buffer + var cmd *exec.Cmd + + stdoutDone := make(chan bool) + stderrDone := make(chan bool) + + if limits != nil && limits.CPUTimeLimit > 0 { + klog.V(3).Infof("Setting CPU limit to %d\n", limits.CPUTimeLimit) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(limits.CPUTimeLimit)*time.Second) + defer cancel() + cmd = execCommandContext(ctx, command, args...) + } else { + cmd = execCommand(command, args...) + } + stdoutIn, err := cmd.StdoutPipe() + if err != nil { + return nil, errors.Wrapf(err, "Couldn't get stdout for %s", command) + } + stderrIn, err := cmd.StderrPipe() + if err != nil { + return nil, errors.Wrapf(err, "Couldn't get stderr for %s", command) + } + + scanner := bufio.NewScanner(stdoutIn) + scanner.Split(scanLinesWithCR) + errScanner := bufio.NewScanner(stderrIn) + errScanner.Split(scanLinesWithCR) + + err = cmd.Start() + if err != nil { + return nil, errors.Wrapf(err, "Couldn't start %s", command) + } + defer func() { + err = cmd.Process.Kill() + klog.Errorf("failed to kill the process; %v", err) + }() + + go processScanner(scanner, &buf, stdoutDone, callback) + go processScanner(errScanner, &errBuf, stderrDone, callback) + + if limits != nil && limits.AddressSpaceLimit > 0 { + klog.V(3).Infof("Setting Address space limit to %d\n", limits.AddressSpaceLimit) + err = SetAddressSpaceLimit(cmd.Process.Pid, limits.AddressSpaceLimit) + if err != nil { + return nil, errors.Wrap(err, "Couldn't set address space limit") + } + } + <-stdoutDone + <-stderrDone + // The wait has to be after the reading channels are finished otherwise there is a race where the wait completes and closes stdout/err before anything + // is read from it. + err = cmd.Wait() + + output := buf.Bytes() + if err != nil { + if logErr { + klog.Errorf("%s failed output is:\n", command) + klog.Errorf("%s\n", string(output)) + klog.Errorf("%s\n", errBuf.String()) + } + return errBuf.Bytes(), errors.Wrapf(err, "%s execution failed", command) + } + return output, nil +} + +func prlimit(pid, limit int, value *syscall.Rlimit) error { + _, _, e1 := syscall.RawSyscall6(syscall.SYS_PRLIMIT64, uintptr(pid), uintptr(limit), uintptr(unsafe.Pointer(value)), 0, 0, 0) + if e1 != 0 { + return errors.Wrapf(e1, "error setting prlimit on %d with value %d on pid %d", limit, value, pid) + } + return nil +} diff --git a/images/cdi-artifact/pkg/util/file.go b/images/cdi-artifact/pkg/util/file.go new file mode 100644 index 0000000000..561ff5e43a --- /dev/null +++ b/images/cdi-artifact/pkg/util/file.go @@ -0,0 +1,317 @@ +package util + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/pkg/errors" + "golang.org/x/sys/unix" + "k8s.io/klog/v2" +) + +// OpenFileOrBlockDevice opens the destination data file, whether it is a block device or regular file +func OpenFileOrBlockDevice(fileName string) (*os.File, error) { + var outFile *os.File + blockSize, err := GetAvailableSpaceBlock(fileName) + if err != nil { + return nil, errors.Wrapf(err, "error determining if block device exists") + } + if blockSize >= 0 { + // Block device found and size determined. + outFile, err = os.OpenFile(fileName, os.O_EXCL|os.O_WRONLY, os.ModePerm) + } else { + // Attempt to create the file with name filePath. If it exists, fail. + outFile, err = os.OpenFile(fileName, os.O_CREATE|os.O_EXCL|os.O_WRONLY, os.ModePerm) + } + if err != nil { + return nil, errors.Wrapf(err, "could not open file %q", fileName) + } + return outFile, nil +} + +// CopyFile copies a file from one location to another. +func CopyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + if err != nil { + return err + } + return out.Close() +} + +// LinkFile symlinks the source to the target +func LinkFile(source, target string) error { + out, err := exec.Command("/usr/bin/ln", "-s", source, target).CombinedOutput() + if err != nil { + fmt.Printf("out [%s]\n", string(out)) + return err + } + return nil +} + +// CopyDir copies a dir from one location to another. +func CopyDir(source, dest string) error { + // get properties of source dir + sourceinfo, err := os.Stat(source) + if err != nil { + return err + } + + // create dest dir + err = os.MkdirAll(dest, sourceinfo.Mode()) + if err != nil { + return err + } + + directory, _ := os.Open(source) + objects, err := directory.Readdir(-1) + + for _, obj := range objects { + src := filepath.Join(source, obj.Name()) + dst := filepath.Join(dest, obj.Name()) + + if obj.IsDir() { + // create sub-directories - recursively + err = CopyDir(src, dst) + if err != nil { + fmt.Println(err) + } + } else { + // perform copy + err = CopyFile(src, dst) + if err != nil { + fmt.Println(err) + } + } + } + return err +} + +// GetAvailableSpace gets the amount of available space at the path specified. +func GetAvailableSpace(path string) (int64, error) { + var stat syscall.Statfs_t + err := syscall.Statfs(path, &stat) + if err != nil { + return int64(-1), err + } + return int64(stat.Bavail) * stat.Bsize, nil +} + +// GetAvailableSpaceBlock gets the amount of available space at the block device path specified. +func GetAvailableSpaceBlock(deviceName string) (int64, error) { + // Check if the file exists and is a device file. + if ok, err := IsDevice(deviceName); !ok || err != nil { + return int64(-1), err + } + + // Device exists, attempt to get size. + cmd := exec.Command(blockdevFileName, "--getsize64", deviceName) + var out bytes.Buffer + var errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + err := cmd.Run() + if err != nil { + return int64(-1), errors.Errorf("%v, %s", err, errBuf.String()) + } + i, err := strconv.ParseInt(strings.TrimSpace(out.String()), 10, 64) + if err != nil { + return int64(-1), err + } + return i, nil +} + +// IsDevice returns true if it's a device file +func IsDevice(deviceName string) (bool, error) { + info, err := os.Stat(deviceName) + if err == nil { + return (info.Mode() & os.ModeDevice) != 0, nil + } + + if os.IsNotExist(err) { + return false, nil + } + + return false, err +} + +// Three functions for zeroing a range in the destination file: + +// PunchHole attempts to zero a range in a file with fallocate, for block devices and pre-allocated files. +func PunchHole(outFile *os.File, start, length int64) error { + klog.V(4).Infof("Punching %d-byte hole at offset %d", length, start) + flags := uint32(unix.FALLOC_FL_PUNCH_HOLE | unix.FALLOC_FL_KEEP_SIZE) + err := syscall.Fallocate(int(outFile.Fd()), flags, start, length) + if err == nil { + _, err = outFile.Seek(length, io.SeekCurrent) // Just to move current file position + } + return err +} + +// AppendZeroWithTruncate resizes the file to append zeroes, meant only for newly-created (empty and zero-length) regular files. +func AppendZeroWithTruncate(outFile *os.File, start, length int64) error { + klog.V(4).Infof("Truncating %d-bytes from offset %d", length, start) + end, err := outFile.Seek(0, io.SeekEnd) + if err != nil { + return err + } + if start != end { + return errors.Errorf("starting offset %d does not match previous ending offset %d, cannot safely append zeroes to this file using truncate", start, end) + } + err = outFile.Truncate(start + length) + if err != nil { + return err + } + _, err = outFile.Seek(0, io.SeekEnd) + return err +} + +var zeroBuffer []byte + +// AppendZeroWithWrite just does normal file writes to the destination, a slow but reliable fallback option. +func AppendZeroWithWrite(outFile *os.File, start, length int64) error { + klog.Infof("Writing %d zero bytes at offset %d", length, start) + offset, err := outFile.Seek(0, io.SeekCurrent) + if err != nil { + return err + } + if start != offset { + return errors.Errorf("starting offset %d does not match previous ending offset %d, cannot safely append zeroes to this file using write", start, offset) + } + if zeroBuffer == nil { // No need to re-allocate this on every write + zeroBuffer = bytes.Repeat([]byte{0}, 32<<20) + } + count := int64(0) + for count < length { + blockSize := int64(len(zeroBuffer)) + remaining := length - count + if remaining < blockSize { + blockSize = remaining + } + written, err := outFile.Write(zeroBuffer[:blockSize]) + if err != nil { + return errors.Wrapf(err, "unable to write %d zeroes at offset %d: %v", length, start+count, err) + } + count += int64(written) + } + return nil +} + +func StreamDataToFile(r io.Reader, fileName string, preallocate bool) (int64, int64, error) { + var outFile *os.File + var bytesRead, bytesWritten int64 + outFile, err := OpenFileOrBlockDevice(fileName) + if err != nil { + return 0, 0, err + } + defer outFile.Close() + + if !preallocate { + var isDevice bool + zeroWriter := AppendZeroWithTruncate + isDevice, err = IsDevice(fileName) + if err != nil { + return 0, 0, err + } + + if isDevice { + zeroWriter = PunchHole + } + + bytesRead, bytesWritten, err = copyWithSparseCheck(outFile, r, zeroWriter) + } else { + bytesRead, err = io.Copy(outFile, r) + bytesWritten = bytesRead + } + + if err != nil { + _ = os.Remove(outFile.Name()) + if strings.Contains(err.Error(), "no space left on device") { + err = errors.Wrapf(err, "unable to write to file") + } + return bytesRead, bytesWritten, err + } + + klog.Infof("Read %d bytes, wrote %d bytes to %s", bytesRead, bytesWritten, outFile.Name()) + + err = outFile.Sync() + + return bytesRead, bytesWritten, err +} + +type zeroWriterFunc func(*os.File, int64, int64) error + +func copyWithSparseCheck(dst *os.File, src io.Reader, zeroWriter zeroWriterFunc) (int64, int64, error) { + klog.Infof("copyWithSparseCheck to %s", dst.Name()) + const buffSize = 32 * 1024 + var bytesRead, bytesWritten int64 + zeroBuf := make([]byte, buffSize) + writeBuf := make([]byte, buffSize) + var writeOffset int64 + for { + nr, er := src.Read(writeBuf) + if nr > 0 { + var nw int + var ew error + if bytes.Equal(writeBuf[0:nr], zeroBuf[0:nr]) { + bytesRead += int64(nr) + } else { + if bytesRead > writeOffset { + // zeroWriter func should seek to bytesRead before returning + ew = zeroWriter(dst, writeOffset, bytesRead-writeOffset) + if ew != nil { + klog.Errorf("Error zeroing range in destination file: %v", ew) + return bytesRead, bytesWritten, ew + } + } + nw, ew = dst.Write(writeBuf[0:nr]) + if nw < 0 || nr < nw { + nw = 0 + if ew == nil { + ew = fmt.Errorf("invalid write result") + } + } + bytesRead += int64(nr) + bytesWritten += int64(nw) + writeOffset = bytesRead + if ew != nil { + return bytesRead, bytesWritten, ew + } + if nr != nw { + return bytesRead, bytesWritten, io.ErrShortWrite + } + } + } + if er != nil { + if er != io.EOF { + return bytesRead, bytesWritten, er + } + break + } + } + if bytesRead > writeOffset { + if err := zeroWriter(dst, writeOffset, bytesRead-writeOffset); err != nil { + klog.Errorf("Error zeroing range in destination file: %v", err) + return bytesRead, bytesWritten, err + } + } + return bytesRead, bytesWritten, nil +} diff --git a/images/cdi-artifact/pkg/util/file_format.go b/images/cdi-artifact/pkg/util/file_format.go new file mode 100644 index 0000000000..bede0753da --- /dev/null +++ b/images/cdi-artifact/pkg/util/file_format.go @@ -0,0 +1,22 @@ +package util + +import "os" + +func GetFormat(path string) (string, error) { + const ( + formatQcow2 = "qcow2" + formatRaw = "raw" + ) + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return formatQcow2, nil + } + return "", err + } + mode := info.Mode() + if mode&os.ModeDevice != 0 { + return formatRaw, nil + } + return formatQcow2, nil +} diff --git a/images/cdi-artifact/pkg/util/prometheus/prometheus.go b/images/cdi-artifact/pkg/util/prometheus/prometheus.go new file mode 100644 index 0000000000..fe57f149a7 --- /dev/null +++ b/images/cdi-artifact/pkg/util/prometheus/prometheus.go @@ -0,0 +1,127 @@ +package prometheus + +import ( + "fmt" + "io" + "net/http" + "os" + "path" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "k8s.io/client-go/util/cert" + "k8s.io/klog/v2" + + "kubevirt.io/containerized-data-importer/pkg/util" +) + +// ProgressReader is a counting reader that reports progress to prometheus. +type ProgressReader struct { + util.CountingReader + metric ProgressMetric + total uint64 + final bool +} + +type ProgressMetric interface { + Add(value float64) + Get() (float64, error) + Delete() +} + +// NewProgressReader creates a new instance of a prometheus updating progress reader. +func NewProgressReader(r io.ReadCloser, metric ProgressMetric, total uint64) *ProgressReader { + promReader := &ProgressReader{ + CountingReader: util.CountingReader{ + Reader: r, + Current: 0, + }, + metric: metric, + total: total, + final: true, + } + + return promReader +} + +// StartTimedUpdate starts the update timer to automatically update every second. +func (r *ProgressReader) StartTimedUpdate() { + // Start the progress update thread. + go r.timedUpdateProgress() +} + +func (r *ProgressReader) timedUpdateProgress() { + cont := true + for cont { + // Update every second. + time.Sleep(time.Second) + cont = r.updateProgress() + } +} + +func (r *ProgressReader) updateProgress() bool { + if r.total > 0 { + finished := r.final && r.Done + currentProgress := 100.0 + if !finished && r.Current < r.total { + currentProgress = float64(r.Current) / float64(r.total) * 100.0 + } + progress, err := r.metric.Get() + if err != nil { + klog.Errorf("updateProgress: failed to read metric; %v", err) + return true // true ==> to try again // todo - how to avoid endless loop in case it's a constant error? + } + if currentProgress > progress { + r.metric.Add(currentProgress - progress) + } + klog.V(1).Infoln(fmt.Sprintf("%.2f", currentProgress)) + return !finished + } + return false +} + +// SetNextReader replaces the current counting reader with a new one, +// for tracking progress over multiple readers. +func (r *ProgressReader) SetNextReader(reader io.ReadCloser, final bool) { + r.CountingReader = util.CountingReader{ + Reader: reader, + Current: r.Current, + Done: false, + } + r.final = final +} + +// StartPrometheusEndpoint starts an http server providing a prometheus endpoint using the passed +// in directory to store the self signed certificates that will be generated before starting the +// http server. +func StartPrometheusEndpoint(certsDirectory string) { + certBytes, keyBytes, err := cert.GenerateSelfSignedCertKey("cloner_target", nil, nil) + if err != nil { + klog.Error("Error generating cert for prometheus") + return + } + + certFile := path.Join(certsDirectory, "tls.crt") + if err = os.WriteFile(certFile, certBytes, 0o600); err != nil { + klog.Error("Error writing cert file") + return + } + + keyFile := path.Join(certsDirectory, "tls.key") + if err = os.WriteFile(keyFile, keyBytes, 0o600); err != nil { + klog.Error("Error writing key file") + return + } + + go func() { + server := &http.Server{ + Addr: ":8443", + ReadHeaderTimeout: 10 * time.Second, + Handler: promhttp.Handler(), + } + + if err := server.ListenAndServeTLS(certFile, keyFile); err != nil { + return + } + }() +} diff --git a/images/cdi-artifact/pkg/util/util.go b/images/cdi-artifact/pkg/util/util.go new file mode 100644 index 0000000000..26bd3aa034 --- /dev/null +++ b/images/cdi-artifact/pkg/util/util.go @@ -0,0 +1,104 @@ +package util + +import ( + "bufio" + "encoding/base64" + "io" + "math" + "os" + "strings" + + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + "kubevirt.io/containerized-data-importer/pkg/common" +) + +const ( + blockdevFileName = "/usr/sbin/blockdev" + // DefaultAlignBlockSize is the alignment size we use to align disk images, its a multiple of all known hardware block sizes 512/4k/8k/32k/64k. + DefaultAlignBlockSize = 1024 * 1024 +) + +// CountingReader is a reader that keeps track of how much has been read. +type CountingReader struct { + Reader io.ReadCloser + Current uint64 + Done bool +} + +// ParseEnvVar provides a wrapper to attempt to fetch the specified env var. +func ParseEnvVar(envVarName string, decode bool) (string, error) { + value := os.Getenv(envVarName) + if decode { + v, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", errors.Errorf("error decoding environment variable %q", envVarName) + } + value = string(v) + } + return value, nil +} + +func (r *CountingReader) Read(p []byte) (n int, err error) { + n, err = r.Reader.Read(p) + r.Current += uint64(n) + r.Done = errors.Is(err, io.EOF) + return n, err +} + +func (r *CountingReader) Close() error { + return r.Reader.Close() +} + +// GetAvailableSpaceByVolumeMode calls another method based on the volumeMode parameter to get the amount of available space. +func GetAvailableSpaceByVolumeMode(volumeMode corev1.PersistentVolumeMode) (int64, error) { + if volumeMode == corev1.PersistentVolumeBlock { + return GetAvailableSpaceBlock(common.WriteBlockPath) + } + return GetAvailableSpace(common.ImporterVolumePath) +} + +// MinQuantity calculates the minimum of two quantities. +func MinQuantity(availableSpace, imageSize *resource.Quantity) resource.Quantity { + if imageSize.Cmp(*availableSpace) == 1 { + return *availableSpace + } + return *imageSize +} + +// WriteTerminationMessage writes the passed in message to the default termination message file. +func WriteTerminationMessage(message string) error { + return WriteTerminationMessageToFile(common.PodTerminationMessageFile, message) +} + +// WriteTerminationMessageToFile writes the passed in message to the passed in message file. +func WriteTerminationMessageToFile(file, message string) error { + message = strings.ReplaceAll(message, "\n", " ") + scanner := bufio.NewScanner(strings.NewReader(message)) + + if scanner.Scan() { + if err := os.WriteFile(file, scanner.Bytes(), 0o600); err != nil { + return errors.Wrap(err, "could not create termination message file") + } + } + return nil +} + +// RoundDown returns the number rounded down to the nearest multiple. +func RoundDown(number, multiple int64) int64 { + return number / multiple * multiple +} + +// RoundUp returns the number rounded up to the nearest multiple. +func RoundUp(number, multiple int64) int64 { + partitions := math.Ceil(float64(number) / float64(multiple)) + return int64(partitions) * multiple +} + +// GetUsableSpace calculates usable space to use taking file system overhead into account. +func GetUsableSpace(filesystemOverhead float64, availableSpace int64) int64 { + spaceWithOverhead := int64(math.Ceil((1 - filesystemOverhead) * float64(availableSpace))) + return RoundDown(spaceWithOverhead, DefaultAlignBlockSize) +} diff --git a/images/cdi-artifact/staging/src/github.com/docker/docker/api/types/versions/compare.go b/images/cdi-artifact/staging/src/github.com/docker/docker/api/types/versions/compare.go new file mode 100644 index 0000000000..a99e9ebfd1 --- /dev/null +++ b/images/cdi-artifact/staging/src/github.com/docker/docker/api/types/versions/compare.go @@ -0,0 +1,79 @@ +// Copyright 2017 The Docker Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package versions + +import ( + "strconv" + "strings" +) + +// compare compares two version strings +// returns -1 if v1 < v2, 1 if v1 > v2, 0 otherwise. +func compare(v1, v2 string) int { + if v1 == v2 { + return 0 + } + var ( + currTab = strings.Split(v1, ".") + otherTab = strings.Split(v2, ".") + ) + + maxVer := len(currTab) + if len(otherTab) > maxVer { + maxVer = len(otherTab) + } + for i := 0; i < maxVer; i++ { + var currInt, otherInt int + + if len(currTab) > i { + currInt, _ = strconv.Atoi(currTab[i]) + } + if len(otherTab) > i { + otherInt, _ = strconv.Atoi(otherTab[i]) + } + if currInt > otherInt { + return 1 + } + if otherInt > currInt { + return -1 + } + } + return 0 +} + +// LessThan checks if a version is less than another +func LessThan(v, other string) bool { + return compare(v, other) == -1 +} + +// LessThanOrEqualTo checks if a version is less than or equal to another +func LessThanOrEqualTo(v, other string) bool { + return compare(v, other) <= 0 +} + +// GreaterThan checks if a version is greater than another +func GreaterThan(v, other string) bool { + return compare(v, other) == 1 +} + +// GreaterThanOrEqualTo checks if a version is greater than or equal to another +func GreaterThanOrEqualTo(v, other string) bool { + return compare(v, other) >= 0 +} + +// Equal checks if a version is equal to another +func Equal(v, other string) bool { + return compare(v, other) == 0 +} diff --git a/images/cdi-artifact/staging/src/github.com/docker/docker/go.mod b/images/cdi-artifact/staging/src/github.com/docker/docker/go.mod new file mode 100644 index 0000000000..7126574947 --- /dev/null +++ b/images/cdi-artifact/staging/src/github.com/docker/docker/go.mod @@ -0,0 +1,3 @@ +module github.com/docker/docker + +go 1.24.0 diff --git a/images/cdi-artifact/staging/src/github.com/docker/docker/registry/registry.go b/images/cdi-artifact/staging/src/github.com/docker/docker/registry/registry.go new file mode 100644 index 0000000000..5c3672b0e7 --- /dev/null +++ b/images/cdi-artifact/staging/src/github.com/docker/docker/registry/registry.go @@ -0,0 +1,15 @@ +// Copyright 2015 The Docker Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry diff --git a/images/cdi-artifact/static_binaries/hello.c b/images/cdi-artifact/static_binaries/hello.c deleted file mode 100644 index 7b70a90577..0000000000 --- a/images/cdi-artifact/static_binaries/hello.c +++ /dev/null @@ -1,6 +0,0 @@ -#include - -int main() { - printf("hello cdi\n"); - return 0; -} \ No newline at end of file diff --git a/images/cdi-artifact/static_binaries/print_file_context.c b/images/cdi-artifact/static_binaries/print_file_context.c deleted file mode 100644 index 34947e4010..0000000000 --- a/images/cdi-artifact/static_binaries/print_file_context.c +++ /dev/null @@ -1,29 +0,0 @@ -#include -#include - -int main(int argc, char *argv[]) { - FILE *fptr; - char myContent[100]; - // Check for correct command-line arguments - if (argc != 2) { - printf("Usage: %s \n", argv[0]); - return 1; - } - - fptr = fopen(argv[1], "r"); // Open in read mode - - if(fptr != NULL) { - // Read the content and print it - while (fgets(myContent,100,fptr)) { - printf("%s", myContent); - } - } else { - perror("Not able to open the file"); - fclose(fptr); - return 1; - } - - - fclose(fptr); // Close the file - return 0; -} \ No newline at end of file diff --git a/images/cdi-artifact/unpack-bundle.sh b/images/cdi-artifact/unpack-bundle.sh deleted file mode 100755 index 1e78812011..0000000000 --- a/images/cdi-artifact/unpack-bundle.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh - -# Copyright 2023 Flant JSC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -DIR=$1 - -cat "$DIR/manifest.json" | jq -r '.[].RepoTags[0]' | \ - while read image; do - (set -x; mkdir -p "$image") - cat "$DIR/manifest.json" | jq -r --arg tag "$image" '.[]| select(.RepoTags[0] == $tag).Layers[]' | \ - while read layer; do - (set -x; tar -C "$image" --overwrite --exclude='./var/run/*' -xf "$DIR/$layer" .) || true - done -done diff --git a/images/cdi-artifact/werf.inc.yaml b/images/cdi-artifact/werf.inc.yaml index c59bb56a47..49815e02d6 100644 --- a/images/cdi-artifact/werf.inc.yaml +++ b/images/cdi-artifact/werf.inc.yaml @@ -1,13 +1,11 @@ --- -{{- $gitRepoName := "3p-containerized-data-importer" }} -{{- $gitRepoUrl := (printf "%s/%s" "deckhouse" $gitRepoName) }} -{{- $version := get $.Core $gitRepoName }} +{{- $version := "local" }} {{- $name := print .ImageName "-dependencies" -}} {{- define "$name" -}} binaries: -- /cdi-binaries/cdi-importer -- /cdi-binaries/cdi-source-update-poller +- /cdi-binaries/pvc-importer +- /cdi-binaries/pvc-target-importer packages: - libnbd - libxml2 @@ -22,44 +20,27 @@ packages: {{ $builderDependencies := include "$name" . | fromYaml }} -image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact -final: false -fromImage: builder/src -git: - - add: {{ .ModuleDir }}/images/{{ .ImageName }} - to: /src - stageDependencies: - install: - - '**/*' - excludePaths: - - patches/README.md -secrets: -- id: SOURCE_REPO - value: {{ $.SOURCE_REPO }} -shell: - install: - - | - echo "Git clone {{ $gitRepoName }} repository..." - git clone --depth=1 --branch {{ $version }} $(cat /run/secrets/SOURCE_REPO)/{{ $gitRepoUrl }} /src/containerized-data-importer - - rm -rf /src/containerized-data-importer/.git - ---- image: {{ .ModuleNamePrefix }}{{ .ImageName }} final: false fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-alt-1.25" "builder/golang-alt-1.25" }} mount: {{- include "mount points for golang builds" . }} secrets: -- id: SOURCE_REPO - value: {{ $.SOURCE_REPO }} - id: GOPROXY value: {{ .GOPROXY }} +git: +- add: {{ .ModuleDir }}/images/{{ .ImageName }} + to: /src + stageDependencies: + install: + - go.mod + - go.sum + setup: + - '**/*' + excludePaths: + - .git + - werf.inc.yaml import: -- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact - add: /src/containerized-data-importer - to: /containerized-data-importer - before: install {{- include "importPackageImages" (list . $builderDependencies.packages "install") -}} shell: install: @@ -75,98 +56,27 @@ shell: export GOPROXY=$(cat /run/secrets/GOPROXY) echo Download Go modules. - cd /containerized-data-importer + cd /src go mod download - go mod vendor - - # Apply patch for json-patch from 3p-cdi repo - git apply --ignore-space-change --ignore-whitespace patches/replace-op-for-evanphx-json-patch-v5-lib.patch - setup: - mkdir /cdi-binaries - - cd /containerized-data-importer + - cd /src - export GOOS=linux - export GOARCH=amd64 - export CGO_ENABLED=0 - - export X_FLAGS="-X kubevirt.io/containerized-data-importer/pkg/version.gitVersion=v{{ $version }}-patched" - - - echo ============== Build cdi-apiserver =========== - - | - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-apiserver" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w $X_FLAGS" -o /cdi-binaries/cdi-apiserver ./cmd/cdi-apiserver`) | nindent 6 }} - - - echo ============== Build cdi-cloner =========== - - | - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-cloner" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/cdi-cloner ./cmd/cdi-cloner`) | nindent 6 }} - - - echo ============== Build cdi-controller =========== - - | - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-controller" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/cdi-controller ./cmd/cdi-controller`) | nindent 6 }} - - - echo ============== Build cdi-uploadproxy =========== - - | - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-uploadproxy" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/cdi-uploadproxy ./cmd/cdi-uploadproxy`) | nindent 6 }} - - - echo ============== Build cdi-image-size-detection =========== - - | - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-image-size-detection" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/cdi-image-size-detection ./tools/cdi-image-size-detection`) | nindent 6 }} - - - echo ============== Build cdi-operator =========== - - | - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-operator" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/cdi-operator ./cmd/cdi-operator`) | nindent 6 }} - - export CGO_ENABLED=1 - - echo ============== Build cdi-importer =========== + - echo ============== Build pvc-importer =========== - | - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-importer" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/cdi-importer ./cmd/cdi-importer`) | nindent 6 }} + {{- $_ := set $ "ProjectName" (list .ImageName "pvc-importer" | join "/") }} + {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/pvc-importer ./cmd/pvc-importer`) | nindent 6 }} - - echo ============== Build cdi-source-update-poller =========== + - echo ============== Build pvc-target-importer =========== - | - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-source-update-poller" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/cdi-source-update-poller ./tools/cdi-source-update-poller`) | nindent 6 }} + {{- $_ := set $ "ProjectName" (list .ImageName "pvc-target-importer" | join "/") }} + {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/pvc-target-importer ./cmd/pvc-target-importer`) | nindent 6 }} - chown -R 64535:64535 /cdi-binaries/* - ls -la /cdi-binaries - ---- -image: {{ .ModuleNamePrefix }}{{ .ImageName }}-cbuilder -final: false -fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-debian-1.25" "builder/golang-alt-1.25" }} -git: - - add: {{ .ModuleDir }}/images/{{ .ImageName }} - to: / - includePaths: - - static_binaries - stageDependencies: - install: - - '*.c' -shell: - install: -{{- if eq $.SVACE_ENABLED "false" }} - {{- include "debian packages proxy" . | nindent 2 }} - - apt-get install --yes musl-dev musl-tools - {{- include "debian packages clean" . | nindent 2 }} -{{- else }} - {{- include "alt packages proxy" . | nindent 2 }} - - apt-get -qq install -y musl-devel musl-devel-static - {{- include "alt packages clean" . | nindent 2 }} -{{- end }} - - | - cd /static_binaries - echo "Building simple app that prints hello cdi" - mkdir -p /bins - {{- $_ := set $ "ProjectName" (list .ImageName "hello" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `musl-gcc -static -Os -o /bins/hello hello.c`) | nindent 6 }} - {{- $_ := set $ "ProjectName" (list .ImageName "printFile" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `musl-gcc -static -Os -o /bins/printFile print_file_context.c`) | nindent 6 }} - strip /bins/hello - strip /bins/printFile diff --git a/images/cdi-cloner/werf.inc.yaml b/images/cdi-cloner/werf.inc.yaml deleted file mode 100644 index 891674f4b6..0000000000 --- a/images/cdi-cloner/werf.inc.yaml +++ /dev/null @@ -1,74 +0,0 @@ ---- -image: {{ .ModuleNamePrefix }}{{ .ImageName }} -fromImage: {{ .ModuleNamePrefix }}distroless -git: - {{- include "image mount points" . }} -import: -- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins - add: /relocate - to: / - before: setup -imageSpec: - config: - entrypoint: ["/usr/bin/cloner-startup"] - user: 64535 ---- -{{- $name := print .ImageName "-dependencies" -}} -{{- define "$name" -}} -binaries: -- /usr/bin/cloner-startup -- /usr/bin/hello -- /usr/bin/cdi-cloner -{{- end -}} - -{{ $virtCDIClonerDependencies := include "$name" . | fromYaml }} - -image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins -final: false -fromImage: {{ .ModuleNamePrefix }}base-alt-p11-binaries -import: -- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-gobuild - add: /cdi-binaries - to: /usr/bin - includePaths: - - cloner-startup - before: install -- image: {{ .ModuleNamePrefix }}cdi-artifact-cbuilder - add: /bins - to: /usr/bin - before: install - includePaths: - - hello -- image: {{ .ModuleNamePrefix }}cdi-artifact - add: /cdi-binaries - to: /usr/bin - includePaths: - - cdi-cloner - before: install -# Source https://github.com/kubevirt/containerized-data-importer/blob/v1.60.3/cmd/cdi-cloner/BUILD.bazel -shell: - install: - - | - /relocate_binaries.sh -i "{{ $virtCDIClonerDependencies.binaries | join " " }}" -o /relocate ---- -image: {{ .ModuleNamePrefix }}{{ .ImageName }}-gobuild -final: false -fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-debian-1.25" "builder/golang-alt-1.25" }} -git: - - add: {{ .ModuleDir }}/images/{{ .ImageName }}/cloner-startup - to: /app - stageDependencies: - install: - - '**/*' -secrets: -- id: GOPROXY - value: {{ .GOPROXY }} -shell: - install: - - | - mkdir -p /cdi-binaries - cd /app - export GOPROXY=$(cat /run/secrets/GOPROXY) - go mod download - {{- $_ := set $ "ProjectName" (list .ImageName "cdi-cloner" | join "/") }} - {{- include "image-build.build" (set $ "BuildCommand" `go build -ldflags="-s -w" -o /cdi-binaries/cloner-startup ./cmd/cloner-startup`) | nindent 6 }} diff --git a/images/cdi-controller/mount-points.yaml b/images/cdi-controller/mount-points.yaml deleted file mode 100644 index d68ce54296..0000000000 --- a/images/cdi-controller/mount-points.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# A list of pre-created mount points for containerd strict mode. -# -# Some volume mounts are ignored: -# - /tmp - already in the 'distroless' base image. - -dirs: - # Create dirs in /run, as /var/run is a symlink to /run. - - /run/cdi/token/keys - - /run/certs/cdi-uploadserver-signer - - /run/certs/cdi-uploadserver-client-signer - - /run/ca-bundle/cdi-uploadserver-signer-bundle - - /run/ca-bundle/cdi-uploadserver-client-signer-bundle - - /kubeconfig.local diff --git a/images/cdi-controller/werf.inc.yaml b/images/cdi-controller/werf.inc.yaml deleted file mode 100644 index 814dde77fa..0000000000 --- a/images/cdi-controller/werf.inc.yaml +++ /dev/null @@ -1,61 +0,0 @@ ---- -image: {{ .ModuleNamePrefix }}{{ .ImageName }} -fromImage: {{ .ModuleNamePrefix }}distroless -git: - {{- include "image mount points" . }} -import: -- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins - add: /relocate - to: / - before: setup -imageSpec: - config: - entrypoint: ["/usr/bin/cdi-controller", "-alsologtostderr"] - user: 64535 ---- -{{- define "cdi-controller-deps" -}} -binaries: - - /usr/bin/cdi-controller -packages: - - tar -{{- end -}} - -{{ $cdiClonerDependencies := include "cdi-controller-deps" . | fromYaml }} - -image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins -final: false -fromImage: {{ .ModuleNamePrefix }}base-alt-p11-binaries -import: -- image: tools/util-linux - add: / - to: /relocate/usr - after: setup - includePaths: - - sbin/blockdev -- image: {{ .ModuleNamePrefix }}cdi-artifact-cbuilder - add: /bins - to: /relocate/usr/bin - after: setup - includePaths: - - printFile -- image: {{ .ModuleNamePrefix }}cdi-artifact - add: /cdi-binaries - to: /usr/bin - includePaths: - - cdi-controller - before: setup -# Source https://github.com/kubevirt/containerized-data-importer/blob/v1.60.3/cmd/cdi-controller/BUILD.bazel -shell: - install: - {{- include "alt packages proxy" . | nindent 2 }} - - | - apt-get install --yes \ - {{ $cdiClonerDependencies.packages | join " " }} - {{- include "alt packages clean" . | nindent 2 }} - setup: - - /relocate_binaries.sh -i "{{ $cdiClonerDependencies.binaries | join " " }}" -o /relocate -# tmp folder need for ready file -# https://github.com/kubevirt/containerized-data-importer/blob/v1.60.3/pkg/operator/resources/namespaced/controller.go#L243 - - | - mkdir -p /relocate/{tmp,var/run/certs/cdi-uploadserver-signer,var/run/certs/cdi-uploadserver-client-signer} - chown -R 64535:64535 /relocate/ diff --git a/images/cdi-operator/mount-points.yaml b/images/cdi-operator/mount-points.yaml deleted file mode 100644 index 624df72961..0000000000 --- a/images/cdi-operator/mount-points.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# A list of pre-created mount points for containerd strict mode. - -dirs: - - /kubeconfig.local diff --git a/images/cdi-operator/werf.inc.yaml b/images/cdi-operator/werf.inc.yaml deleted file mode 100644 index c720c33d50..0000000000 --- a/images/cdi-operator/werf.inc.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -image: {{ .ModuleNamePrefix }}{{ .ImageName }} -fromImage: {{ .ModuleNamePrefix }}distroless -git: - {{- include "image mount points" . }} -import: -- image: {{ .ModuleNamePrefix }}cdi-artifact - add: /cdi-binaries - to: /usr/bin - includePaths: - - cdi-operator - before: setup -imageSpec: - config: - entrypoint: ["/usr/bin/cdi-operator"] - user: 64535 diff --git a/images/hooks/cmd/virtualization-module-hooks/register.go b/images/hooks/cmd/virtualization-module-hooks/register.go index 5f028f9dc7..d1ed1913a0 100644 --- a/images/hooks/cmd/virtualization-module-hooks/register.go +++ b/images/hooks/cmd/virtualization-module-hooks/register.go @@ -18,6 +18,7 @@ package main import ( _ "github.com/deckhouse/virtualization/hooks/pkg/hooks/ca-discovery" + _ "github.com/deckhouse/virtualization/hooks/pkg/hooks/cdi-cleanup" _ "github.com/deckhouse/virtualization/hooks/pkg/hooks/copy-custom-certificate" _ "github.com/deckhouse/virtualization/hooks/pkg/hooks/discover-kube-apiserver-feature-gates" _ "github.com/deckhouse/virtualization/hooks/pkg/hooks/discovery-clusterip-service-for-dvcr" diff --git a/images/hooks/pkg/hooks/cdi-cleanup/hook.go b/images/hooks/pkg/hooks/cdi-cleanup/hook.go new file mode 100644 index 0000000000..dc7fc71fc8 --- /dev/null +++ b/images/hooks/pkg/hooks/cdi-cleanup/hook.go @@ -0,0 +1,152 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cdi_cleanup + +import ( + "context" + "fmt" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/pkg/registry" + "github.com/deckhouse/virtualization/hooks/pkg/settings" +) + +const cdiCleanupNamespace = settings.ModuleNamespace + +var _ = registry.RegisterFunc(config, cleanup) + +var config = &pkg.HookConfig{ + OnBeforeHelm: &pkg.OrderedConfig{Order: 15}, + Queue: fmt.Sprintf("modules/%s", settings.ModuleName), +} + +type staleResource struct { + apiVersion string + kind string + namespace string + name string +} + +func cleanup(_ context.Context, input *pkg.HookInput) error { + for _, resource := range staleCDIResources() { + input.PatchCollector.DeleteInBackground(resource.apiVersion, resource.kind, resource.namespace, resource.name) + } + + return nil +} + +func staleCDIResources() []staleResource { + resources := make([]staleResource, 0, 64) + + resources = append(resources, cdiOperatorResources()...) + resources = append(resources, cdiAdmissionResources()...) + resources = append(resources, cdiRuntimeResources()...) + resources = append(resources, cdiCRDResources()...) + + return resources +} + +func cdiOperatorResources() []staleResource { + return []staleResource{ + namespaced("apps/v1", "Deployment", "cdi-operator"), + namespaced("policy/v1", "PodDisruptionBudget", "cdi-operator"), + namespaced("autoscaling.k8s.io/v1", "VerticalPodAutoscaler", "cdi-operator"), + } +} + +func cdiAdmissionResources() []staleResource { + return []staleResource{ + cluster("admissionregistration.k8s.io/v1", "MutatingWebhookConfiguration", "cdi-internal-virtualization-api-datavolume-mutate"), + cluster("admissionregistration.k8s.io/v1", "MutatingWebhookConfiguration", "cdi-internal-virtualization-api-pvc-mutate"), + cluster("admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration", "cdi-internal-virtualization-api-dataimportcron-validate"), + cluster("admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration", "cdi-internal-virtualization-api-populator-validate"), + cluster("admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration", "cdi-internal-virtualization-api-datavolume-validate"), + cluster("admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration", "cdi-internal-virtualization-api-validate"), + cluster("admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration", "cdi-internal-virtualization-objecttransfer-api-validate"), + cluster("apiregistration.k8s.io/v1", "APIService", "v1beta1.upload.cdi.kubevirt.io"), + cluster("apiregistration.k8s.io/v1", "APIService", "v1beta1.upload.cdi.internal.virtualization.deckhouse.io"), + cluster("rbac.authorization.k8s.io/v1", "ClusterRole", "d8:containerized-data-importer:cdi-operator"), + cluster("rbac.authorization.k8s.io/v1", "ClusterRoleBinding", "d8:containerized-data-importer:cdi-operator"), + cluster("rbac.authorization.k8s.io/v1", "ClusterRoleBinding", "d8:virtualization:cdi-operator-rbac-proxy"), + cluster("rbac.authorization.k8s.io/v1", "ClusterRoleBinding", "d8:virtualization:cdi-deployment-rbac-proxy"), + } +} + +func cdiRuntimeResources() []staleResource { + return []staleResource{ + namespaced("apps/v1", "Deployment", "cdi-apiserver"), + namespaced("apps/v1", "Deployment", "cdi-deployment"), + namespaced("v1", "Service", "cdi-api"), + namespaced("v1", "Service", "cdi-prometheus-metrics"), + namespaced("v1", "Service", "cdi-uploadproxy"), + namespaced("v1", "ConfigMap", "cdi-operator-leader-election-helper"), + namespaced("v1", "ConfigMap", "cdi-apiserver-signer-bundle"), + namespaced("v1", "ConfigMap", "cdi-uploadproxy-signer-bundle"), + namespaced("v1", "ConfigMap", "cdi-uploadserver-signer-bundle"), + namespaced("v1", "ConfigMap", "cdi-uploadserver-client-signer-bundle"), + namespaced("v1", "Secret", "cdi-apiserver-signer"), + namespaced("v1", "Secret", "cdi-apiserver-server-cert"), + namespaced("v1", "Secret", "cdi-uploadproxy-signer"), + namespaced("v1", "Secret", "cdi-uploadproxy-server-cert"), + namespaced("v1", "Secret", "cdi-uploadserver-signer"), + namespaced("v1", "Secret", "cdi-uploadserver-client-signer"), + namespaced("v1", "Secret", "cdi-uploadserver-client-cert"), + namespaced("v1", "ServiceAccount", "cdi-operator"), + namespaced("v1", "ServiceAccount", "cdi-apiserver"), + namespaced("v1", "ServiceAccount", "cdi-sa"), + namespaced("rbac.authorization.k8s.io/v1", "Role", "cdi-operator"), + namespaced("rbac.authorization.k8s.io/v1", "Role", "cdi-apiserver"), + namespaced("rbac.authorization.k8s.io/v1", "Role", "cdi-deployment"), + namespaced("rbac.authorization.k8s.io/v1", "RoleBinding", "cdi-operator"), + namespaced("rbac.authorization.k8s.io/v1", "RoleBinding", "cdi-apiserver"), + namespaced("rbac.authorization.k8s.io/v1", "RoleBinding", "cdi-deployment"), + namespaced("policy/v1", "PodDisruptionBudget", "cdi-apiserver"), + namespaced("policy/v1", "PodDisruptionBudget", "cdi-deployment"), + namespaced("autoscaling.k8s.io/v1", "VerticalPodAutoscaler", "cdi-apiserver"), + namespaced("autoscaling.k8s.io/v1", "VerticalPodAutoscaler", "cdi-deployment"), + } +} + +func cdiCRDResources() []staleResource { + return []staleResource{ + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "cdiconfigs.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "datavolumes.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "dataimportcrons.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "datasources.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "objecttransfers.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "openstackvolumepopulators.forklift.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "ovirtvolumepopulators.forklift.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "volumeclonesources.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "volumeimportsources.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "volumeuploadsources.cdi.kubevirt.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "internalvirtualizationdatavolumes.cdi.internal.virtualization.deckhouse.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "internalvirtualizationdataimportcrons.cdi.internal.virtualization.deckhouse.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "internalvirtualizationdatasources.cdi.internal.virtualization.deckhouse.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "internalvirtualizationobjecttransfers.cdi.internal.virtualization.deckhouse.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "internalvirtualizationvolumeclonesources.cdi.internal.virtualization.deckhouse.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "internalvirtualizationvolumeimportsources.cdi.internal.virtualization.deckhouse.io"), + cluster("apiextensions.k8s.io/v1", "CustomResourceDefinition", "internalvirtualizationvolumeuploadsources.cdi.internal.virtualization.deckhouse.io"), + } +} + +func namespaced(apiVersion, kind, name string) staleResource { + return staleResource{apiVersion: apiVersion, kind: kind, namespace: cdiCleanupNamespace, name: name} +} + +func cluster(apiVersion, kind, name string) staleResource { + return staleResource{apiVersion: apiVersion, kind: kind, name: name} +} diff --git a/images/hooks/pkg/hooks/cdi-cleanup/hook_test.go b/images/hooks/pkg/hooks/cdi-cleanup/hook_test.go new file mode 100644 index 0000000000..9bcdc87871 --- /dev/null +++ b/images/hooks/pkg/hooks/cdi-cleanup/hook_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cdi_cleanup + +import ( + "strings" + "testing" +) + +func TestStaleCDIResources(t *testing.T) { + resources := staleCDIResources() + + assertHasResource(t, resources, staleResource{ + apiVersion: "apps/v1", + kind: "Deployment", + namespace: cdiCleanupNamespace, + name: "cdi-apiserver", + }) + assertHasResource(t, resources, staleResource{ + apiVersion: "apps/v1", + kind: "Deployment", + namespace: cdiCleanupNamespace, + name: "cdi-deployment", + }) + assertHasResource(t, resources, staleResource{ + apiVersion: "apps/v1", + kind: "Deployment", + namespace: cdiCleanupNamespace, + name: "cdi-operator", + }) + assertHasResource(t, resources, staleResource{ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + name: "datavolumes.cdi.kubevirt.io", + }) + + for _, resource := range resources { + if strings.Contains(resource.name, "storageprofiles") { + t.Fatalf("StorageProfile CRD must not be removed by CDI cleanup hook: %#v", resource) + } + if resource.kind == "InternalVirtualizationCDI" || resource.name == "config" && resource.apiVersion == "cdi.internal.virtualization.deckhouse.io/v1beta1" { + t.Fatalf("CDI config must not be removed by CDI cleanup hook: %#v", resource) + } + } +} + +func assertHasResource(t *testing.T, resources []staleResource, want staleResource) { + t.Helper() + + for _, resource := range resources { + if resource == want { + return + } + } + + t.Fatalf("expected stale CDI resource not found: %#v", want) +} diff --git a/images/kube-api-rewriter/STRUCTURE.md b/images/kube-api-rewriter/STRUCTURE.md index bdbf4a2d52..95f788aa92 100644 --- a/images/kube-api-rewriter/STRUCTURE.md +++ b/images/kube-api-rewriter/STRUCTURE.md @@ -123,49 +123,6 @@ flowchart virt-api-proxy <----> kube-api virt-handler-proxy <----> kube-api - subgraph cdi ["CDI"] - subgraph cdi-operator-deploy ["`Deploy/cdi-operator`"] - cdi-operator-proxy{{"container: - proxy"}} - cdi-operator("`container: - virt-handler`") - cdi-operator --> cdi-operator-proxy - cdi-operator-proxy --> cdi-operator - end - - subgraph cdi-deployment-deploy ["`Deploy/cdi-deployment`"] - cdi-deployment-proxy{{"container: - proxy"}} - cdi-deployment("`container: - cdi-eployment`") - cdi-deployment --> cdi-deployment-proxy - cdi-deployment-proxy --> cdi-deployment - end - - subgraph cdi-api-deploy ["`Deploy/cdi-api`"] - cdi-api-proxy{{"container: - proxy"}} - cdi-api("`container: - cdi-api`") - cdi-api --> cdi-api-proxy - cdi-api-proxy --> cdi-api - end - - subgraph cdi-exportproxy-deploy ["`Deploy/cdi-exportproxy`"] - cdi-exportproxy-proxy{{"container: - proxy"}} - cdi-exportproxy("`container: - cdi-exportproxy`") - cdi-exportproxy --> cdi-exportproxy-proxy - cdi-exportproxy-proxy --> cdi-exportproxy - end - end - kube-api <----> cdi-operator-proxy - kube-api <----> cdi-deployment-proxy - kube-api <----> cdi-api-proxy - kube-api <----> cdi-exportproxy-proxy - - subgraph d8virt ["D8 API"] subgraph d8-virt-deploy ["Deploy/virtualization-controller"] d8-virt-controller-proxy("`container: @@ -223,31 +180,13 @@ block-beta virtexportproxyproxy --> kubeapiserver space:5 - cdioperatorproxy --> kubeapiserver - cdiapiproxy --> kubeapiserver - cdideploymentproxy --> kubeapiserver - cdiuploadproxyproxy --> kubeapiserver virtualizationcontrollerproxy --> kubeapiserver - %% Proxies in CDI Pods. - cdioperatorproxy(["proxy"]) - cdiapiproxy(["proxy"]) - cdideploymentproxy(["proxy"]) - cdiuploadproxyproxy(["proxy"]) virtualizationcontrollerproxy(["proxy"]) - %% Links inside CDI Pods. space:5 - cdioperator --> cdioperatorproxy - cdiapi--> cdiapiproxy - cdideployment --> cdideploymentproxy - cdiuploadproxy --> cdiuploadproxyproxy virtualizationcontroller --> virtualizationcontrollerproxy - cdioperator["cdi-operator"] - cdiapi["cdi-api"] - cdideployment["cdi-deployment"] - cdiuploadproxy["cdi-uploadproxy"] virtualizationcontroller["virtualization- controller"] ``` @@ -263,7 +202,7 @@ block-beta ``` - Add a volume and a volumeMount to pass new kubeconfig as file to the main container. - Set KUBECONFIG variable in the main container. File should contain configuration to connect to proxy port. - - Note: kubevirt containers use --kubeconfig flag, cdi containers use KUBECONFIG env variable. + - Note: KubeVirt containers use --kubeconfig flag. - Add a new sidecar container with the proxy. - Set WEBHOOK_ADDRESS if webhook proxying is required. - Add volumeMount with a certificate and set WEBHOOK_CERT_FILE and WEBHOOK_KEY_FILE to use the certificate. diff --git a/images/kube-api-rewriter/mount-points.yaml b/images/kube-api-rewriter/mount-points.yaml index fa5ef6daed..9288d9eb42 100644 --- a/images/kube-api-rewriter/mount-points.yaml +++ b/images/kube-api-rewriter/mount-points.yaml @@ -3,5 +3,3 @@ dirs: - /etc/virt-operator/certificates - /etc/virt-api/certificates - # Create dirs in /run, as /var/run is a symlink to /run. - - /run/certs/cdi-apiserver-server-cert diff --git a/images/cdi-importer/mount-points.yaml b/images/pvc-importer/mount-points.yaml similarity index 70% rename from images/cdi-importer/mount-points.yaml rename to images/pvc-importer/mount-points.yaml index f926961f28..781258512a 100644 --- a/images/cdi-importer/mount-points.yaml +++ b/images/pvc-importer/mount-points.yaml @@ -1,6 +1,6 @@ # A list of pre-created mount points for containerd strict mode. # -# See https://github.com/deckhouse/3p-containerized-data-importer/blob/d5fa5124b8a645521843814fffecdf385b74b379/pkg/controller/import-controller.go#L962 +# See images/cdi-artifact/cmd/pvc-importer. # # Some volume mounts are ignored: # - /extraheaders - Etra headers not implemented in virtualization-controller. @@ -14,4 +14,3 @@ dirs: - /proxycerts - /scratch - /shared - diff --git a/images/cdi-importer/werf.inc.yaml b/images/pvc-importer/werf.inc.yaml similarity index 69% rename from images/cdi-importer/werf.inc.yaml rename to images/pvc-importer/werf.inc.yaml index 6ae9a3ecb6..8a7d736114 100644 --- a/images/cdi-importer/werf.inc.yaml +++ b/images/pvc-importer/werf.inc.yaml @@ -8,21 +8,23 @@ import: add: /relocate to: / before: setup -# Source https://github.com/kubevirt/containerized-data-importer/blob/v1.60.3/cmd/cdi-importer/BUILD.bazel +# Binaries are built from images/cdi-artifact by images/cdi-artifact/werf.inc.yaml. imageSpec: config: - entrypoint: ["/usr/bin/cdi-importer", "-alsologtostderr"] + entrypoint: ["/usr/bin/pvc-importer", "-alsologtostderr"] user: 64535 --- -{{- define "cdi-importer-deps" -}} +{{- define "pvc-importer-deps" -}} binaries: # nbd bins and libs - /usr/sbin/nbdkit + - /usr/bin/nbdcopy - /usr/lib64/nbdkit/filters/*.so - /usr/lib64/nbdkit/plugins/*.so - # CDI binaries - - /usr/bin/cdi-image-size-detection /usr/bin/cdi-importer /usr/bin/cdi-source-update-poller + # PVC importer binary + - /usr/bin/pvc-importer + - /usr/bin/pvc-target-importer # QEMU bins - /usr/bin/qemu-img # do not include glibc, it will be replaced by one from qemu-img @@ -40,7 +42,7 @@ packages: - linux-pam zstd {{- end -}} -{{ $cdiImporterDependencies := include "cdi-importer-deps" . | fromYaml }} +{{ $pvcImporterDependencies := include "pvc-importer-deps" . | fromYaml }} image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins final: false @@ -59,19 +61,18 @@ import: to: /usr/bin before: setup includePaths: - - cdi-image-size-detection - - cdi-importer - - cdi-source-update-poller + - pvc-importer + - pvc-target-importer - image: {{ .ModuleNamePrefix }}qemu add: /qemu-img to: /qemu-img before: install -{{- include "importPackageImages" (list . $cdiImporterDependencies.packages "install") -}} +{{- include "importPackageImages" (list . $pvcImporterDependencies.packages "install") -}} shell: install: - | echo "Install packages" - PKGS="{{ $cdiImporterDependencies.packages | join " " }}" + PKGS="{{ $pvcImporterDependencies.packages | join " " }}" PKGS+=" qemu-img" for pkg in $PKGS; do cp -a /$pkg/. / @@ -79,4 +80,4 @@ shell: done setup: - | - /relocate_binaries.sh -i "{{ $cdiImporterDependencies.binaries | join " " }}" -o /relocate + /relocate_binaries.sh -i "{{ $pvcImporterDependencies.binaries | join " " }}" -o /relocate diff --git a/images/virt-artifact/werf.inc.yaml b/images/virt-artifact/werf.inc.yaml index 0d75ebb629..3bc6fae850 100644 --- a/images/virt-artifact/werf.inc.yaml +++ b/images/virt-artifact/werf.inc.yaml @@ -15,6 +15,7 @@ secrets: shell: install: - | + echo 'Cache bust install: {{ now | date "Mon Jan 2 15:04:05 MST 2006" }}' echo "Git clone {{ $gitRepoName }} repository..." git clone --depth=1 $(cat /run/secrets/SOURCE_REPO)/{{ $gitRepoUrl }} --branch {{ $tag }} /src/kubevirt @@ -68,6 +69,7 @@ shell: install: - | + echo 'Cache bust install: {{ now | date "Mon Jan 2 15:04:05 MST 2006" }}' # Install packages PKGS="{{ $builderDependencies.packages | join " " }}" for pkg in $PKGS; do @@ -87,6 +89,8 @@ shell: done setup: + - | + echo 'Cache bust setup: {{ now | date "Mon Jan 2 15:04:05 MST 2006" }}' - mkdir /kubevirt-binaries - mkdir /kubevirt-config-files - cd /kubevirt diff --git a/images/virtual-disk-importer/werf.inc.yaml b/images/virtual-disk-importer/werf.inc.yaml new file mode 100644 index 0000000000..e57a71021b --- /dev/null +++ b/images/virtual-disk-importer/werf.inc.yaml @@ -0,0 +1,7 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }} +fromImage: {{ .ModuleNamePrefix }}pvc-importer +imageSpec: + config: + entrypoint: ["/usr/bin/pvc-importer", "-alsologtostderr"] + user: 64535 diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index 1a6e0f1263..e969a0e43f 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -49,7 +49,9 @@ import ( mc "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig" mcapi "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig/api" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice" + "github.com/deckhouse/virtualization-controller/pkg/controller/populator" "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice" + "github.com/deckhouse/virtualization-controller/pkg/controller/storageprofile" "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice" "github.com/deckhouse/virtualization-controller/pkg/controller/vd" "github.com/deckhouse/virtualization-controller/pkg/controller/vdsnapshot" @@ -115,7 +117,6 @@ func main() { } var logDebugControllerList []string - fmt.Print(len(logDebugControllerList)) logDebugControllerListRaw := os.Getenv(logDebugControllerListEnv) if logDebugControllerListRaw != "" { logDebugControllerListRaw = strings.ReplaceAll(logDebugControllerListRaw, " ", "") @@ -359,13 +360,25 @@ func main() { } vdLogger := logger.NewControllerLogger(vd.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) - if _, err = vd.NewController(ctx, mgr, vdLogger, importSettings.ImporterImage, importSettings.UploaderImage, importSettings.Requirements, dvcrSettings, vdStorageClassSettings); err != nil { + if _, err = vd.NewController(ctx, mgr, vdLogger, importSettings.ImporterImage, importSettings.DiskImporterImage, importSettings.UploaderImage, importSettings.Requirements, dvcrSettings, vdStorageClassSettings); err != nil { log.Error(err.Error()) os.Exit(1) } viLogger := logger.NewControllerLogger(vi.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) - if _, err = vi.NewController(ctx, mgr, viLogger, importSettings.ImporterImage, importSettings.UploaderImage, importSettings.BounderImage, importSettings.Requirements, dvcrSettings, viStorageClassSettings); err != nil { + if _, err = vi.NewController(ctx, mgr, viLogger, importSettings.ImporterImage, importSettings.DiskImporterImage, importSettings.UploaderImage, importSettings.BounderImage, importSettings.Requirements, dvcrSettings, viStorageClassSettings); err != nil { + log.Error(err.Error()) + os.Exit(1) + } + + populatorLogger := logger.NewControllerLogger(populator.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) + if _, err = populator.NewController(mgr, populatorLogger, importSettings.DiskImporterImage, importSettings.Requirements, dvcrSettings); err != nil { + log.Error(err.Error()) + os.Exit(1) + } + + storageProfileLogger := logger.NewControllerLogger(storageprofile.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) + if _, err = storageprofile.NewController(mgr, storageProfileLogger); err != nil { log.Error(err.Error()) os.Exit(1) } diff --git a/images/virtualization-artifact/pkg/common/annotations/annotations.go b/images/virtualization-artifact/pkg/common/annotations/annotations.go index b72fa48663..524718103e 100644 --- a/images/virtualization-artifact/pkg/common/annotations/annotations.go +++ b/images/virtualization-artifact/pkg/common/annotations/annotations.go @@ -63,7 +63,7 @@ const ( // AnnProvisionerTolerations provides a const for tolerations to use for provisioners. AnnProvisionerTolerations = AnnAPIGroup + "/provisioner-tolerations" - // AnnProvisionerName provides a name of data volume provisioner. + // AnnProvisionerName provides a name of the PVC provisioner. AnnProvisionerName = AnnAPIGroup + "/provisioner-name" // AnnProvisionerNameLegacy provides a legacy name of data volume provisioner. AnnProvisionerNameLegacy = AnnAPIGroupLegacy + "/provisioner-name" @@ -144,6 +144,11 @@ const ( UploaderServiceLabel = "service" + // PVCImportRoleLabel distinguishes source/target importer pods in a host-assigned PVC clone. + PVCImportRoleLabel = LabelsPrefix + "/pvc-import-role" + // PVCImportRoleSource marks the nbdkit pod that serves the source PVC over NBD. + PVCImportRoleSource = "source" + // AppKubernetesManagedByLabel is the Kubernetes recommended managed-by label. AppKubernetesManagedByLabel = "app.kubernetes.io/managed-by" // AppKubernetesComponentLabel is the Kubernetes recommended component label. @@ -167,6 +172,16 @@ const ( // AnnDataExportRequest is the annotation for indicating that export requested. AnnDataExportRequest = "storage.deckhouse.io/data-export-request" + // PVC population annotations. + // AnnPVCPopulationStrategy stores the strategy used by populator-controller. + AnnPVCPopulationStrategy = AnnAPIGroupV + "/pvc-population-strategy" + // AnnPVCPopulationSourcePVC stores the source PVC name for PVC-backed population. + AnnPVCPopulationSourcePVC = AnnAPIGroupV + "/pvc-population-source-pvc" + // AnnPVCPopulationSourceDVCR stores the source DVCR image path for registry-backed population. + AnnPVCPopulationSourceDVCR = AnnAPIGroupV + "/pvc-population-source-dvcr" + // AnnPVCPopulationDone marks target PVCs already populated by populator-controller. + AnnPVCPopulationDone = AnnAPIGroupV + "/pvc-population-done" + // TODO: remove deprecated annotations in the v1 version. // AnnStorageClassName is the annotation for indicating that storage class name. (USED IN STORAGE sds controllers) AnnStorageClassName = AnnAPIGroupV + "/storage-class-name" diff --git a/images/virtualization-artifact/pkg/common/common.go b/images/virtualization-artifact/pkg/common/common.go index bf356dfb96..6df7e68bd6 100644 --- a/images/virtualization-artifact/pkg/common/common.go +++ b/images/virtualization-artifact/pkg/common/common.go @@ -33,6 +33,14 @@ func ErrQuotaExceeded(err error) bool { return strings.Contains(err.Error(), "exceeded quota:") } +// ErrNamespaceTerminating reports whether err is an admission rejection caused by the +// target namespace being terminated (e.g. "unable to create new content in namespace X +// because it is being terminated"). This is a transient, environment-driven condition +// (the namespace and its resources are being torn down), not a controller failure. +func ErrNamespaceTerminating(err error) bool { + return err != nil && strings.Contains(err.Error(), "because it is being terminated") +} + func BoolFloat64(b bool) float64 { if b { return 1 diff --git a/images/virtualization-artifact/pkg/common/consts.go b/images/virtualization-artifact/pkg/common/consts.go index 41ddc972c7..f9c69b76a8 100644 --- a/images/virtualization-artifact/pkg/common/consts.go +++ b/images/virtualization-artifact/pkg/common/consts.go @@ -21,6 +21,8 @@ const ( FilesystemOverheadVar = "FILESYSTEM_OVERHEAD" // OwnerUID provides the UID of the owner entity (either PVC or DV) OwnerUID = "OWNER_UID" + // ImporterNBDEndpoint provides the NBD source URL for pvc-target-importer. + ImporterNBDEndpoint = "IMPORTER_NBD_ENDPOINT" // BounderContainerName provides a constant to use as a name for bounder Container BounderContainerName = "d8v-dvcr-bounder" @@ -34,6 +36,8 @@ const ( UploaderPort = 80 // ImporterPodImageNameVar is a name of variable with the image name for the importer Pod ImporterPodImageNameVar = "IMPORTER_IMAGE" + // DiskImporterPodImageNameVar is a name of variable with the image name for the VirtualDisk CDI importer Pod. + DiskImporterPodImageNameVar = "DISK_IMPORTER_IMAGE" // UploaderPodImageNameVar is a name of variable with the image name for the uploader Pod UploaderPodImageNameVar = "UPLOADER_IMAGE" // BounderPodImageNameVar is a name of variable with the image name for the bounder Pod diff --git a/images/virtualization-artifact/pkg/common/datavolume/util.go b/images/virtualization-artifact/pkg/common/datavolume/util.go deleted file mode 100644 index e23c6005b6..0000000000 --- a/images/virtualization-artifact/pkg/common/datavolume/util.go +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package datavolume - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - cdiv1beta1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" -) - -const DataVolumeKind = "DataVolume" - -// MakeOwnerReference makes controller owner reference for a DataVolume object. -// NOTE: GetObjectKind resets after creation, hence this method with hardcoded -// GVK as a workaround. -func MakeOwnerReference(dv *cdiv1beta1.DataVolume) metav1.OwnerReference { - gvk := schema.GroupVersionKind{ - Group: cdiv1beta1.SchemeGroupVersion.Group, - Version: cdiv1beta1.SchemeGroupVersion.Version, - Kind: DataVolumeKind, - } - return *metav1.NewControllerRef(dv, gvk) -} diff --git a/images/virtualization-artifact/pkg/common/imageformat/format.go b/images/virtualization-artifact/pkg/common/imageformat/format.go index 708bd2ee93..03057ec0f5 100644 --- a/images/virtualization-artifact/pkg/common/imageformat/format.go +++ b/images/virtualization-artifact/pkg/common/imageformat/format.go @@ -16,13 +16,27 @@ limitations under the License. package imageformat -import "strings" +import ( + "strings" + + corev1 "k8s.io/api/core/v1" +) const ( - FormatISO = "iso" - FormatRAW = "raw" + FormatISO = "iso" + FormatRAW = "raw" + FormatQCOW2 = "qcow2" ) func IsISO(format string) bool { return strings.ToLower(format) == FormatISO } + +// StorageFormat returns the image format actually stored on a PVC. +// Block volumes are populated as a flat raw disk; filesystem volumes keep a qcow2 file. +func StorageFormat(pvc *corev1.PersistentVolumeClaim) string { + if pvc != nil && pvc.Spec.VolumeMode != nil && *pvc.Spec.VolumeMode == corev1.PersistentVolumeBlock { + return FormatRAW + } + return FormatQCOW2 +} diff --git a/images/virtualization-artifact/pkg/common/imageformat/format_test.go b/images/virtualization-artifact/pkg/common/imageformat/format_test.go new file mode 100644 index 0000000000..ddb5436b34 --- /dev/null +++ b/images/virtualization-artifact/pkg/common/imageformat/format_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package imageformat + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" +) + +var _ = Describe("StorageFormat", func() { + It("returns raw for block PVCs", func() { + pvc := &corev1.PersistentVolumeClaim{ + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeMode: ptr.To(corev1.PersistentVolumeBlock), + }, + } + Expect(StorageFormat(pvc)).To(Equal(FormatRAW)) + }) + + It("returns qcow2 for filesystem PVCs", func() { + pvc := &corev1.PersistentVolumeClaim{ + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + }, + } + Expect(StorageFormat(pvc)).To(Equal(FormatQCOW2)) + }) + + It("returns qcow2 when volume mode is unset", func() { + Expect(StorageFormat(&corev1.PersistentVolumeClaim{})).To(Equal(FormatQCOW2)) + Expect(StorageFormat(nil)).To(Equal(FormatQCOW2)) + }) +}) diff --git a/images/virtualization-artifact/pkg/common/datavolume/importer.go b/images/virtualization-artifact/pkg/common/imageformat/imageformat_suite_test.go similarity index 70% rename from images/virtualization-artifact/pkg/common/datavolume/importer.go rename to images/virtualization-artifact/pkg/common/imageformat/imageformat_suite_test.go index f93dc755b3..e80ffaf602 100644 --- a/images/virtualization-artifact/pkg/common/datavolume/importer.go +++ b/images/virtualization-artifact/pkg/common/imageformat/imageformat_suite_test.go @@ -1,5 +1,5 @@ /* -Copyright 2024 Flant JSC +Copyright 2026 Flant JSC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,12 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package datavolume +package imageformat -import "k8s.io/apimachinery/pkg/types" +import ( + "testing" -const importerPrimePrefix = "importer-prime-" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) -func GetImporterPrimeName(pvcUID types.UID) string { - return importerPrimePrefix + string(pvcUID) +func TestImageFormat(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ImageFormat Suite") } diff --git a/images/virtualization-artifact/pkg/common/network_policy/network_policy.go b/images/virtualization-artifact/pkg/common/network_policy/network_policy.go index 43834c7318..1770efbbf8 100644 --- a/images/virtualization-artifact/pkg/common/network_policy/network_policy.go +++ b/images/virtualization-artifact/pkg/common/network_policy/network_policy.go @@ -19,6 +19,7 @@ package networkpolicy import ( "context" + corev1 "k8s.io/api/core/v1" netv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -29,7 +30,32 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" ) -func CreateNetworkPolicy(ctx context.Context, c client.Client, obj metav1.Object, sup supplements.DataVolumeSupplement, finalizer string) error { +// PVCImporterIngressPeers returns ingress peers for pvc-importer pods: +// - other CDI-labelled importer pods (host-assigned source/target NBD traffic); +// - the virtualization-controller namespace (progress metrics scrape). +func PVCImporterIngressPeers(controllerNamespace string) []netv1.NetworkPolicyPeer { + peers := []netv1.NetworkPolicyPeer{{ + PodSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: annotations.AppLabel, + Operator: metav1.LabelSelectorOpIn, + Values: []string{annotations.CDILabelValue}, + }}, + }, + }} + if controllerNamespace != "" { + peers = append(peers, netv1.NetworkPolicyPeer{ + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + corev1.LabelMetadataName: controllerNamespace, + }, + }, + }) + } + return peers +} + +func CreateNetworkPolicy(ctx context.Context, c client.Client, obj metav1.Object, sup supplements.Generator, finalizer string) error { npName := sup.NetworkPolicy() networkPolicy := netv1.NetworkPolicy{ TypeMeta: metav1.TypeMeta{ diff --git a/images/virtualization-artifact/pkg/common/network_policy/network_policy_test.go b/images/virtualization-artifact/pkg/common/network_policy/network_policy_test.go new file mode 100644 index 0000000000..96bf43aaab --- /dev/null +++ b/images/virtualization-artifact/pkg/common/network_policy/network_policy_test.go @@ -0,0 +1,51 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package networkpolicy + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" +) + +func TestPVCImporterIngressPeers(t *testing.T) { + t.Run("allows CDI pods and the controller namespace", func(t *testing.T) { + peers := PVCImporterIngressPeers("d8-virtualization") + if len(peers) != 2 { + t.Fatalf("expected 2 peers, got %d", len(peers)) + } + cdiExpr := peers[0].PodSelector.MatchExpressions[0] + if cdiExpr.Key != annotations.AppLabel || + cdiExpr.Operator != metav1.LabelSelectorOpIn || + len(cdiExpr.Values) != 1 || cdiExpr.Values[0] != annotations.CDILabelValue { + t.Fatalf("unexpected CDI peer selector: %#v", cdiExpr) + } + wantNS := map[string]string{corev1.LabelMetadataName: "d8-virtualization"} + if got := peers[1].NamespaceSelector.MatchLabels; got == nil || got[corev1.LabelMetadataName] != wantNS[corev1.LabelMetadataName] { + t.Fatalf("unexpected controller namespace peer: %#v", got) + } + }) + + t.Run("allows only CDI pods when controller namespace is empty", func(t *testing.T) { + if len(PVCImporterIngressPeers("")) != 1 { + t.Fatal("expected a single CDI peer when controller namespace is empty") + } + }) +} diff --git a/images/virtualization-artifact/pkg/common/percent/percent.go b/images/virtualization-artifact/pkg/common/percent/percent.go index e4c184d818..bc6c9c8038 100644 --- a/images/virtualization-artifact/pkg/common/percent/percent.go +++ b/images/virtualization-artifact/pkg/common/percent/percent.go @@ -30,7 +30,23 @@ func ScalePercentage(percent string, low, high float64) string { } scaled := pctVal*((high-low)/100) + low - return fmt.Sprintf("%.1f%%", scaled) + return Format(scaled) +} + +// Format renders an import-progress percentage for display. The boundary values +// 0 and 100 are rendered without a fractional part ("0%", "100%"); every other +// value keeps a single decimal ("32.1%", "50.0%"). This keeps the reported +// progress consistent (e.g. always "50.0%" instead of a mix of "50%" and +// "50.0%"). +func Format(value float64) string { + switch value { + case 0: + return "0%" + case 100: + return "100%" + default: + return fmt.Sprintf("%.1f%%", value) + } } var possibleFloatRe = regexp.MustCompile(`[0-9eE\-+.]+`) diff --git a/images/virtualization-artifact/pkg/common/percent/percent_test.go b/images/virtualization-artifact/pkg/common/percent/percent_test.go index a300ee49ac..8882252736 100644 --- a/images/virtualization-artifact/pkg/common/percent/percent_test.go +++ b/images/virtualization-artifact/pkg/common/percent/percent_test.go @@ -56,7 +56,7 @@ func Test_ScalePercent(t *testing.T) { "0%", 0.0, 50.0, - "0.0%", + "0%", }, { "0%", @@ -74,7 +74,7 @@ func Test_ScalePercent(t *testing.T) { "100%", 50.0, 100.0, - "100.0%", + "100%", }, } diff --git a/images/virtualization-artifact/pkg/common/provisioner/node_placement.go b/images/virtualization-artifact/pkg/common/provisioner/node_placement.go index d49b814563..5417bfc595 100644 --- a/images/virtualization-artifact/pkg/common/provisioner/node_placement.go +++ b/images/virtualization-artifact/pkg/common/provisioner/node_placement.go @@ -31,6 +31,11 @@ type NodePlacement struct { // See https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ for more info. // These are additional tolerations other than default ones. Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + // Node is the name of the node the consuming VirtualMachine is scheduled on. + // It is used to pin import helper resources (prime PVC, importer pod) to the + // VM's node so a WaitForFirstConsumer node-local volume is provisioned there + // and can be attached to the VM. Empty when the VM is not yet scheduled. + Node string `json:"-"` } func IsNodePlacementChanged(nodePlacement *NodePlacement, obj client.Object) (bool, error) { diff --git a/images/virtualization-artifact/pkg/common/storageclass/provisioner.go b/images/virtualization-artifact/pkg/common/storageclass/provisioner.go new file mode 100644 index 0000000000..5476f50018 --- /dev/null +++ b/images/virtualization-artifact/pkg/common/storageclass/provisioner.go @@ -0,0 +1,135 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package storageclass provides helpers to resolve the CSI provisioner that +// backs the storage of virtualization sources (VirtualDisk, VirtualImage, +// VirtualDiskSnapshot). They are used by admission webhooks to forbid creating +// a block device from a source that lives on a different CSI driver. +package storageclass + +import ( + "context" + "fmt" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +// ProvisionerOf returns the provisioner (CSI driver) of the named StorageClass. +// An empty scName yields an empty provisioner without error. +func ProvisionerOf(ctx context.Context, c client.Client, scName string) (string, error) { + if scName == "" { + return "", nil + } + + sc, err := object.FetchObject(ctx, types.NamespacedName{Name: scName}, c, &storagev1.StorageClass{}) + if err != nil { + return "", fmt.Errorf("get storage class %q: %w", scName, err) + } + if sc == nil { + return "", fmt.Errorf("storage class %q was not found", scName) + } + + return sc.Provisioner, nil +} + +// ProvisionerOfVirtualDisk resolves the provisioner backing a VirtualDisk. +// The boolean is false when the provisioner cannot be determined yet (the disk +// is missing or not ready), in which case the caller should skip validation. +func ProvisionerOfVirtualDisk(ctx context.Context, c client.Client, namespace, name string) (string, bool, error) { + vd, err := object.FetchObject(ctx, types.NamespacedName{Namespace: namespace, Name: name}, c, &v1alpha2.VirtualDisk{}) + if err != nil { + return "", false, err + } + if vd == nil || vd.Status.Phase != v1alpha2.DiskReady || vd.Status.StorageClassName == "" { + return "", false, nil + } + + provisioner, err := ProvisionerOf(ctx, c, vd.Status.StorageClassName) + if err != nil { + return "", false, err + } + + return provisioner, provisioner != "", nil +} + +// ProvisionerOfVirtualImage resolves the provisioner backing a VirtualImage. +// The boolean is false when the provisioner cannot be determined (the image is +// missing, not ready, or stored on the container registry rather than a PVC). +func ProvisionerOfVirtualImage(ctx context.Context, c client.Client, namespace, name string) (string, bool, error) { + vi, err := object.FetchObject(ctx, types.NamespacedName{Namespace: namespace, Name: name}, c, &v1alpha2.VirtualImage{}) + if err != nil { + return "", false, err + } + if vi == nil || + vi.Status.Phase != v1alpha2.ImageReady || + vi.Spec.Storage == v1alpha2.StorageContainerRegistry || + vi.Status.StorageClassName == "" { + return "", false, nil + } + + provisioner, err := ProvisionerOf(ctx, c, vi.Status.StorageClassName) + if err != nil { + return "", false, err + } + + return provisioner, provisioner != "", nil +} + +// ProvisionerOfVirtualDiskSnapshot resolves the provisioner that backs the +// source PVC of a VirtualDiskSnapshot. The boolean is false when it cannot be +// determined (the snapshot is missing, not ready, or its source PVC is gone). +func ProvisionerOfVirtualDiskSnapshot(ctx context.Context, c client.Client, namespace, name string) (string, bool, error) { + vdSnapshot, err := object.FetchObject(ctx, types.NamespacedName{Namespace: namespace, Name: name}, c, &v1alpha2.VirtualDiskSnapshot{}) + if err != nil { + return "", false, err + } + if vdSnapshot == nil || + vdSnapshot.Status.Phase != v1alpha2.VirtualDiskSnapshotPhaseReady || + vdSnapshot.Status.VolumeSnapshotName == "" { + return "", false, nil + } + + vs, err := object.FetchObject(ctx, types.NamespacedName{Namespace: namespace, Name: vdSnapshot.Status.VolumeSnapshotName}, c, &vsv1.VolumeSnapshot{}) + if err != nil { + return "", false, err + } + if vs == nil || vs.Spec.Source.PersistentVolumeClaimName == nil || *vs.Spec.Source.PersistentVolumeClaimName == "" { + return "", false, nil + } + + pvc, err := object.FetchObject(ctx, types.NamespacedName{Namespace: namespace, Name: *vs.Spec.Source.PersistentVolumeClaimName}, c, &corev1.PersistentVolumeClaim{}) + if err != nil { + return "", false, err + } + if pvc == nil { + return "", false, nil + } + + provisioner := pvc.Annotations[annotations.AnnStorageProvisioner] + if provisioner == "" { + provisioner = pvc.Annotations[annotations.AnnStorageProvisionerDeprecated] + } + + return provisioner, provisioner != "", nil +} diff --git a/images/virtualization-artifact/pkg/config/load_dvcr_settings.go b/images/virtualization-artifact/pkg/config/load_dvcr_settings.go index 5deadedef3..81f0fbaa83 100644 --- a/images/virtualization-artifact/pkg/config/load_dvcr_settings.go +++ b/images/virtualization-artifact/pkg/config/load_dvcr_settings.go @@ -53,6 +53,7 @@ const ( func LoadDVCRSettingsFromEnvs(controllerNamespace string) (*dvcr.Settings, error) { dvcrSettings := &dvcr.Settings{ + ControllerNamespace: controllerNamespace, AuthSecret: os.Getenv(DVCRAuthSecretVar), AuthSecretNamespace: os.Getenv(DVCRAuthSecretNSVar), CertsSecret: os.Getenv(DVCRCertsSecretVar), diff --git a/images/virtualization-artifact/pkg/config/load_import_settings.go b/images/virtualization-artifact/pkg/config/load_import_settings.go index f05781ab2b..0130461c33 100644 --- a/images/virtualization-artifact/pkg/config/load_import_settings.go +++ b/images/virtualization-artifact/pkg/config/load_import_settings.go @@ -32,10 +32,11 @@ const ( ) type ImportSettings struct { - ImporterImage string - UploaderImage string - BounderImage string - Requirements corev1.ResourceRequirements + ImporterImage string + DiskImporterImage string + UploaderImage string + BounderImage string + Requirements corev1.ResourceRequirements } func LoadImportSettingsFromEnv() (ImportSettings, error) { @@ -46,6 +47,11 @@ func LoadImportSettingsFromEnv() (ImportSettings, error) { return ImportSettings{}, err } + settings.DiskImporterImage, err = GetRequiredEnvVar(common.DiskImporterPodImageNameVar) + if err != nil { + return ImportSettings{}, err + } + settings.UploaderImage, err = GetRequiredEnvVar(common.UploaderPodImageNameVar) if err != nil { return ImportSettings{}, err diff --git a/images/virtualization-artifact/pkg/controller/bounder/bounder.go b/images/virtualization-artifact/pkg/controller/bounder/bounder.go index 8a7316cd16..8e96db7fb1 100644 --- a/images/virtualization-artifact/pkg/controller/bounder/bounder.go +++ b/images/virtualization-artifact/pkg/controller/bounder/bounder.go @@ -88,6 +88,10 @@ func (imp *Bounder) makeBounderPodSpec() (*corev1.Pod, error) { }, Labels: map[string]string{ annotations.HeritageLabel: annotations.HeritageValue, + // The bounder pod is internal scaffolding - it triggers binding + // of a WaitForFirstConsumer PVC by getting itself scheduled to + // a node - and must not be billed against the project quota. + annotations.QuotaExcludeLabel: annotations.QuotaExcludeValue, }, Finalizers: []string{ imp.PodSettings.Finalizer, diff --git a/images/virtualization-artifact/pkg/controller/conditions/getter.go b/images/virtualization-artifact/pkg/controller/conditions/getter.go index c17ba47b68..5a46bb61c3 100644 --- a/images/virtualization-artifact/pkg/controller/conditions/getter.go +++ b/images/virtualization-artifact/pkg/controller/conditions/getter.go @@ -19,7 +19,6 @@ package conditions import ( corev1 "k8s.io/api/core/v1" virtv1 "kubevirt.io/api/core/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" ) func GetPodCondition(condType corev1.PodConditionType, conds []corev1.PodCondition) (corev1.PodCondition, bool) { @@ -32,23 +31,6 @@ func GetPodCondition(condType corev1.PodConditionType, conds []corev1.PodConditi return corev1.PodCondition{}, false } -const ( - DVRunningConditionType cdiv1.DataVolumeConditionType = "Running" - DVRunningConditionPendingReason string = "Pending" - DVQoutaNotExceededConditionType cdiv1.DataVolumeConditionType = "QuotaNotExceeded" - DVImagePullFailedReason string = "ImagePullFailed" -) - -func GetDataVolumeCondition(condType cdiv1.DataVolumeConditionType, conds []cdiv1.DataVolumeCondition) (cdiv1.DataVolumeCondition, bool) { - for _, cond := range conds { - if cond.Type == condType { - return cond, true - } - } - - return cdiv1.DataVolumeCondition{}, false -} - func GetKVVMCondition(condType virtv1.VirtualMachineConditionType, conds []virtv1.VirtualMachineCondition) (virtv1.VirtualMachineCondition, bool) { for _, cond := range conds { if cond.Type == condType { diff --git a/images/virtualization-artifact/pkg/controller/cvi/internal/life_cycle.go b/images/virtualization-artifact/pkg/controller/cvi/internal/life_cycle.go index b88d972a07..99bee3ab46 100644 --- a/images/virtualization-artifact/pkg/controller/cvi/internal/life_cycle.go +++ b/images/virtualization-artifact/pkg/controller/cvi/internal/life_cycle.go @@ -101,5 +101,33 @@ func (h LifeCycleHandler) Handle(ctx context.Context, cvi *v1alpha2.ClusterVirtu return reconcile.Result{}, err } + normalizeProgress(cvi) + return result, nil } + +// normalizeProgress enforces the phase/progress invariants on the final status: +// - a CVI that has not yet entered Provisioning ("" or Pending) must NOT +// expose any progress percentage. Progress describes how far the import +// has advanced, so it is meaningful only once the import has actually +// started; any earlier setter (some source paths optimistically populate +// "0%" before they have decided whether the phase will advance to +// Provisioning in this reconcile) is unconditionally cleared here so that +// consumers never observe e.g. "Pending 0%"; +// - a CVI in the Provisioning phase must always expose a progress +// percentage; until the importer reports real progress it defaults to +// "0%"; +// - a CVI parked in WaitForUserUpload has not received any data yet, so +// its progress is always "0%". +func normalizeProgress(cvi *v1alpha2.ClusterVirtualImage) { + switch cvi.Status.Phase { + case "", v1alpha2.ImagePending: + cvi.Status.Progress = "" + case v1alpha2.ImageProvisioning: + if cvi.Status.Progress == "" { + cvi.Status.Progress = "0%" + } + case v1alpha2.ImageWaitForUserUpload: + cvi.Status.Progress = "0%" + } +} diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/dv.go b/images/virtualization-artifact/pkg/controller/kvbuilder/dv.go deleted file mode 100644 index b8ec43d433..0000000000 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/dv.go +++ /dev/null @@ -1,128 +0,0 @@ -/* -Copyright 2024 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package kvbuilder - -import ( - "encoding/json" - "fmt" - - 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" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - - "github.com/deckhouse/virtualization-controller/pkg/common" - "github.com/deckhouse/virtualization-controller/pkg/common/annotations" - "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" - "github.com/deckhouse/virtualization-controller/pkg/common/pvc" - "github.com/deckhouse/virtualization-controller/pkg/common/resource_builder" -) - -// DV is a helper to construct DataVolume to import an image from DVCR onto PVC. -type DV struct { - resource_builder.ResourceBuilder[*cdiv1.DataVolume] -} - -func NewDV(name types.NamespacedName) *DV { - return &DV{ - ResourceBuilder: resource_builder.NewResourceBuilder( - &cdiv1.DataVolume{ - TypeMeta: metav1.TypeMeta{ - Kind: "DataVolume", - APIVersion: cdiv1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: name.Namespace, - Name: name.Name, - Annotations: map[string]string{ - "cdi.kubevirt.io/storage.deleteAfterCompletion": "false", - }, - }, - Spec: cdiv1.DataVolumeSpec{ - Source: &cdiv1.DataVolumeSource{}, - }, - }, resource_builder.ResourceBuilderOptions{}, - ), - } -} - -func (b *DV) SetPVC(storageClassName *string, - size resource.Quantity, - accessMode corev1.PersistentVolumeAccessMode, - volumeMode corev1.PersistentVolumeMode, -) { - b.Resource.Spec.PVC = pvc.CreateSpec(storageClassName, - size, - accessMode, - volumeMode, - ) -} - -func (b *DV) SetImmediate() { - b.AddAnnotation("cdi.kubevirt.io/storage.bind.immediate.requested", "true") -} - -func (b *DV) SetDataSource(source *cdiv1.DataVolumeSource) { - b.Resource.Spec.Source = source -} - -func (b *DV) SetNodePlacement(nodePlacement *provisioner.NodePlacement) error { - if nodePlacement == nil || len(nodePlacement.Tolerations) == 0 { - return nil - } - - anno := b.Resource.GetAnnotations() - if anno == nil { - anno = make(map[string]string) - } - - data, err := json.Marshal(nodePlacement.Tolerations) - if err != nil { - return fmt.Errorf("failed to marshal tolerations: %w", err) - } - - anno[annotations.AnnProvisionerTolerations] = string(data) - - err = provisioner.KeepNodePlacementTolerations(nodePlacement, b.Resource) - if err != nil { - return fmt.Errorf("failed to keep node placement: %w", err) - } - - return nil -} - -func (b *DV) SetRegistryDataSource(imageName, authSecret, caBundleConfigMap string) { - url := common.DockerRegistrySchemePrefix + imageName - - dataSource := cdiv1.DataVolumeSourceRegistry{ - URL: &url, - } - - if authSecret != "" { - dataSource.SecretRef = &authSecret - } - if caBundleConfigMap != "" { - dataSource.CertConfigMap = &caBundleConfigMap - } - - b.Resource.Spec.Source.Registry = &dataSource -} - -func (b *DV) SetBlankDataSource() { - b.Resource.Spec.Source.Blank = &cdiv1.DataVolumeBlankImage{} -} diff --git a/images/virtualization-artifact/pkg/controller/moduleconfig/vi_storageclass_validator.go b/images/virtualization-artifact/pkg/controller/moduleconfig/vi_storageclass_validator.go index 2dbcfd3ea1..af1dce231a 100644 --- a/images/virtualization-artifact/pkg/controller/moduleconfig/vi_storageclass_validator.go +++ b/images/virtualization-artifact/pkg/controller/moduleconfig/vi_storageclass_validator.go @@ -79,13 +79,15 @@ func (v viStorageClassValidator) validateStorageClass(ctx context.Context, scNam } for _, cps := range scProfile.Status.ClaimPropertySets { - if slices.Contains(cps.AccessModes, corev1.ReadWriteMany) && *cps.VolumeMode == corev1.PersistentVolumeBlock { + if cps.VolumeMode != nil && + (slices.Contains(cps.AccessModes, corev1.ReadWriteOnce) || slices.Contains(cps.AccessModes, corev1.ReadWriteMany)) && + (*cps.VolumeMode == corev1.PersistentVolumeBlock || *cps.VolumeMode == corev1.PersistentVolumeFilesystem) { return admission.Warnings{}, nil } } return admission.Warnings{}, fmt.Errorf( - "the storage class %q lacks of capabilities to support 'Virtual Images on PVC' function; use StorageClass that supports volume mode 'Block' and access mode 'ReadWriteMany'", + "the storage class %q lacks of capabilities to support 'Virtual Images on PVC' function; use StorageClass that supports volume mode 'Block' or 'Filesystem' and access mode 'ReadWriteOnce' or 'ReadWriteMany'", scName, ) } diff --git a/images/virtualization-artifact/pkg/controller/monitoring/progress.go b/images/virtualization-artifact/pkg/controller/monitoring/progress.go index c908257db3..e6bf28a042 100644 --- a/images/virtualization-artifact/pkg/controller/monitoring/progress.go +++ b/images/virtualization-artifact/pkg/controller/monitoring/progress.go @@ -30,6 +30,7 @@ import ( "k8s.io/apimachinery/pkg/util/net" "github.com/deckhouse/virtualization-controller/pkg/common/humanize_bytes" + "github.com/deckhouse/virtualization-controller/pkg/common/percent" ) var httpClient *http.Client @@ -57,17 +58,32 @@ func GetImportProgressFromPod(ownerUID string, pod *corev1.Pod) (*ImportProgress return extractProgress(progressReport, ownerUID) } -// extractProgress parses final report and extracts metrics: -// registry_progress{ownerUID="b856691e-1038-11e9-a5ab-525500d15501"} 47.68095477934807 -// registry_current_speed{ownerUID="b856691e-1038-11e9-a5ab-525500d15501"} 2.12e+06 -// registry_average_speed{ownerUID="b856691e-1038-11e9-a5ab-525500d15501"} 2.3832862149406234e+06 +// extractProgress parses the final report and extracts metrics. Two metric +// families are recognized: +// +// - registry_progress / registry_current_speed / registry_average_speed are +// emitted by dvcr-importer / dvcr-uploader pods (the "first half" import +// into DVCR for HTTP / Registry / Upload data sources). +// - kubevirt_cdi_import_progress_total is emitted by the pvc-importer pod +// (the "second half" import from DVCR into the target PVC; for ObjectRef +// CVI / VI it is also the only import pod). +// +// Example lines: +// +// registry_progress{ownerUID="b856691e-1038-11e9-a5ab-525500d15501"} 47.6809 +// registry_current_speed{ownerUID="b856691e-1038-11e9-a5ab-525500d15501"} 2.12e+06 +// registry_average_speed{ownerUID="b856691e-1038-11e9-a5ab-525500d15501"} 2.38e+06 +// kubevirt_cdi_import_progress_total{ownerUID="b856691e-1038-11e9-a5ab-525500d15501"} 73.42 func extractProgress(report, ownerUID string) (*ImportProgress, error) { if report == "" { return nil, nil } // Note: invalid float format will be checked later using ParseFloat. - progressRe := regexp.MustCompile(`registry_progress\{ownerUID\="` + ownerUID + `"\} ([0-9e\+\-\.]+)`) + // Match either the dvcr-importer's registry_progress or the pvc-importer's + // kubevirt_cdi_import_progress_total metric. Both are reported in the same + // 0..100 scale, so either value is a valid pod-local progress percentage. + progressRe := regexp.MustCompile(`(?:registry_progress|kubevirt_cdi_import_progress_total)\{ownerUID\="` + ownerUID + `"\} ([0-9e\+\-\.]+)`) avgSpeedRe := regexp.MustCompile(`registry_average_speed\{ownerUID\="` + ownerUID + `"\} ([0-9e\+\-\.]+)`) curSpeedRe := regexp.MustCompile(`registry_current_speed\{ownerUID\="` + ownerUID + `"\} ([0-9e\+\-\.]+)`) @@ -78,7 +94,7 @@ func extractProgress(report, ownerUID string) (*ImportProgress, error) { raw := match[1] val, err := strconv.ParseFloat(raw, 64) if err != nil { - return nil, fmt.Errorf("parse registry_progress metric: %w", err) + return nil, fmt.Errorf("parse import progress metric: %w", err) } res.progress = val } @@ -107,7 +123,7 @@ func extractProgress(report, ownerUID string) (*ImportProgress, error) { } func (p *ImportProgress) Progress() string { - return fmt.Sprintf("%.1f%%", p.progress) + return percent.Format(p.progress) } func (p *ImportProgress) ProgressRaw() float64 { @@ -152,12 +168,28 @@ func BuildHTTPClient(httpClient *http.Client) *http.Client { } httpClient = &http.Client{ Transport: tr, - Timeout: time.Second, + // The progress reconcile loop scrapes /metrics on the importer pod + // every reconcile (~1s). The controller-side contract is that + // Status.Progress must advance with at most a 2s gap while the + // importer pod is alive, so the per-scrape budget is bounded to + // strictly less than that interval. A 1.5s budget keeps the scrape + // well within the next reconcile tick: a busy /metrics endpoint + // fails fast (returned as a real error to the caller, not silently + // swallowed), so the next reconcile retries immediately rather than + // freezing on a 5s wait that would itself blow the gap budget. + Timeout: scrapeTimeout, } } return httpClient } +// scrapeTimeout is the per-attempt budget for fetching the progress +// metric from the importer pod. It is intentionally smaller than the +// reconcile interval so that, on a slow /metrics, we still get a fresh +// reconcile tick (and thus a fresh Status.Progress write) within the +// 2s no-gap budget that the test contract enforces. +const scrapeTimeout = 1500 * time.Millisecond + // GetPodMetricsPort returns, if exists, the metrics port from the passed pod func GetPodMetricsPort(pod *corev1.Pod) (int, error) { for _, container := range pod.Spec.Containers { @@ -170,16 +202,24 @@ func GetPodMetricsPort(pod *corev1.Pod) (int, error) { return 0, fmt.Errorf("metrics port not found in pod %s", pod.Name) } -// GetProgressReportFromURL fetches the progress report from the passed URL according to a specific regular expression +// GetProgressReportFromURL fetches the progress report from the passed URL according to a specific regular expression. +// +// "Pod not yet listening" - connection refused / no route to host - is a +// benign signal that the importer pod has not started serving its /metrics +// endpoint yet, and is reported as ("", nil) so the caller keeps the +// previous progress and retries on the next reconcile. +// +// Any other failure (in particular a timeout against an alive but busy +// /metrics endpoint) is a real failure to scrape: returning ("", nil) here +// would silently freeze Status.Progress for the entire stall window. The +// caller logs and retries, but the error is no longer hidden, which keeps +// the no-gap contract honest. func GetProgressReportFromURL(url string, httpClient *http.Client) (string, error) { resp, err := httpClient.Get(url) if err != nil { if net.IsConnectionRefused(err) { return "", nil } - if net.IsTimeout(err) { - return "", nil - } if strings.Contains(err.Error(), "no route to host") { return "", nil } diff --git a/images/virtualization-artifact/pkg/controller/monitoring/progress_test.go b/images/virtualization-artifact/pkg/controller/monitoring/progress_test.go index 04a449e381..2adafb1c72 100644 --- a/images/virtualization-artifact/pkg/controller/monitoring/progress_test.go +++ b/images/virtualization-artifact/pkg/controller/monitoring/progress_test.go @@ -34,3 +34,50 @@ registry_current_speed{ownerUID="11"} 2.345632345432e+6 t.Fatalf("%s is not expected human readable value for raw value %v", p.CurSpeed(), p.CurSpeedRaw()) } } + +// Test_ExtractProgress_CDIImporter verifies the parser accepts the +// pvc-importer's progress metric name and returns the value as the pod-local +// progress. pvc-importer does not emit registry_*_speed series, so download +// speed remains zero in this case. +func Test_ExtractProgress_CDIImporter(t *testing.T) { + p, err := extractProgress(` +kubevirt_cdi_import_progress_total{ownerUID="22"} 73.42 +`, `22`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p == nil { + t.Fatalf("expected progress, got nil") + } + if p.ProgressRaw() != 73.42 { + t.Fatalf("expected 73.42, got %v", p.ProgressRaw()) + } + if p.AvgSpeedRaw() != 0 || p.CurSpeedRaw() != 0 { + t.Fatalf("expected zero speed (pvc-importer does not emit speed), got avg=%d cur=%d", p.AvgSpeedRaw(), p.CurSpeedRaw()) + } +} + +// Test_ExtractProgress_DVCRTakesPrecedence covers the case where both metric +// families happen to be present on the same scrape (e.g. mixed report from a +// previous run). The dvcr-importer's registry_progress name is listed first in +// the alternation, so it wins; speeds from the same family are picked up too. +func Test_ExtractProgress_DVCRTakesPrecedence(t *testing.T) { + p, err := extractProgress(` +registry_progress{ownerUID="33"} 47.6 +registry_average_speed{ownerUID="33"} 1.0e+6 +registry_current_speed{ownerUID="33"} 2.0e+6 +kubevirt_cdi_import_progress_total{ownerUID="33"} 99.0 +`, `33`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p == nil { + t.Fatalf("expected progress, got nil") + } + if p.ProgressRaw() != 47.6 { + t.Fatalf("expected 47.6, got %v", p.ProgressRaw()) + } + if p.AvgSpeedRaw() != 1_000_000 || p.CurSpeedRaw() != 2_000_000 { + t.Fatalf("expected avg=1e6 cur=2e6, got avg=%d cur=%d", p.AvgSpeedRaw(), p.CurSpeedRaw()) + } +} diff --git a/images/virtualization-artifact/pkg/controller/populator/populator_controller.go b/images/virtualization-artifact/pkg/controller/populator/populator_controller.go new file mode 100644 index 0000000000..a3821a1049 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/populator/populator_controller.go @@ -0,0 +1,414 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package populator + +import ( + "context" + "fmt" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + ControllerName = "populator-controller" + + PodVerbose = "3" + PodPullPolicy = string(corev1.PullIfNotPresent) + + pvcSupplementPrefix = "pvc" +) + +type Reconciler struct { + client client.Client + pvc *service.PersistentVolumeClaimService + log *log.Logger +} + +func NewController( + mgr manager.Manager, + log *log.Logger, + diskImporterImage string, + requirements corev1.ResourceRequirements, + dvcrSettings *dvcr.Settings, +) (controller.Controller, error) { + reconciler := &Reconciler{ + client: mgr.GetClient(), + log: log, + pvc: service.NewPersistentVolumeClaimService(mgr.GetClient(), dvcrSettings, nil, service.DiskImporterConfig{ + Image: diskImporterImage, + ResourceRequirements: requirements, + PullPolicy: PodPullPolicy, + Verbose: PodVerbose, + }), + } + + ctr, err := controller.New(ControllerName, mgr, controller.Options{ + Reconciler: reconciler, + LogConstructor: logger.NewConstructor(log), + }) + if err != nil { + return nil, err + } + + if err := addWatches(mgr, ctr); err != nil { + return nil, err + } + + log.Info("Initialized PVC populator controller") + return ctr, nil +} + +func hasPopulationStrategy(obj client.Object) bool { + return obj.GetAnnotations()[annotations.AnnPVCPopulationStrategy] != "" +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + pvc, err := object.FetchObject(ctx, req.NamespacedName, r.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + return reconcile.Result{}, fmt.Errorf("fetch pvc: %w", err) + } + if pvc == nil || pvc.Annotations[annotations.AnnPVCPopulationStrategy] == "" { + return reconcile.Result{}, nil + } + + strategy := pvc.Annotations[annotations.AnnPVCPopulationStrategy] + if pvc.Annotations[annotations.AnnPVCPopulationDone] == "true" { + return reconcile.Result{}, r.cleanup(ctx, pvc, strategy) + } + + switch strategy { + case service.PopulationStrategyCSIClone: + _, err := r.reconcileBoundOnly(ctx, pvc) + return reconcile.Result{}, err + case service.PopulationStrategySnapshot: + if err := r.ensureSnapshot(ctx, pvc); err != nil { + return reconcile.Result{}, err + } + marked, err := r.reconcileBoundOnly(ctx, pvc) + if err != nil { + return reconcile.Result{}, err + } + if !marked { + return reconcile.Result{}, nil + } + return reconcile.Result{}, r.cleanup(ctx, pvc, strategy) + case service.PopulationStrategyDVCR, service.PopulationStrategyHostAssigned: + return r.reconcileImporter(ctx, pvc, strategy) + default: + return reconcile.Result{}, nil + } +} + +func (r *Reconciler) reconcileBoundOnly(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (bool, error) { + if pvc.Status.Phase != corev1.ClaimBound { + return false, nil + } + return true, r.markDone(ctx, pvc) +} + +func (r *Reconciler) reconcileImporter(ctx context.Context, pvc *corev1.PersistentVolumeClaim, strategy string) (reconcile.Result, error) { + wffc, err := r.isWaitForFirstConsumer(ctx, pvc) + if err != nil { + return reconcile.Result{}, err + } + + owner, sup, err := r.ownerAndSupplements(ctx, pvc) + if err != nil { + return reconcile.Result{}, err + } + if _, ok := owner.(*v1alpha2.VirtualDisk); ok && wffc && pvc.Annotations[service.SelectedNodeAnnotation] == "" { + r.log.Info("PVC population waiting for WaitForFirstConsumer node selection", + "namespace", pvc.Namespace, + "pvc", pvc.Name, + "strategy", strategy, + ) + return reconcile.Result{}, nil + } + + beforePods, err := r.importerPodSnapshots(ctx, sup, strategy) + if err != nil { + return reconcile.Result{}, err + } + + source := sourceFromAnnotations(pvc, strategy, sup) + if err := r.pvc.Import(ctx, pvc, source, owner, sup, nil); err != nil { + return reconcile.Result{}, fmt.Errorf("import to pvc: %w", err) + } + + afterImportPods, err := r.importerPodSnapshots(ctx, sup, strategy) + if err != nil { + return reconcile.Result{}, err + } + r.logImporterPodTransitions(pvc, strategy, beforePods, afterImportPods) + + rebindStarted := r.rebindPending(ctx, pvc, sup, strategy) + if rebindStarted { + r.log.Info("PVC population rebind started", + "namespace", pvc.Namespace, + "pvc", pvc.Name, + "strategy", strategy, + ) + } + + phase, err := r.pvc.WaitForImport(ctx, pvc, source, owner, sup, nil) + if err != nil { + return reconcile.Result{}, fmt.Errorf("wait for pvc import: %w", err) + } + + afterWaitPods, err := r.importerPodSnapshots(ctx, sup, strategy) + if err != nil { + return reconcile.Result{}, err + } + r.logImporterPodTransitions(pvc, strategy, afterImportPods, afterWaitPods) + + switch phase { + case corev1.PodSucceeded: + if rebindStarted { + r.log.Info("PVC population rebind finished", + "namespace", pvc.Namespace, + "pvc", pvc.Name, + "strategy", strategy, + ) + } + if err := r.markDone(ctx, pvc); err != nil { + return reconcile.Result{}, err + } + r.log.Info("PVC population finished", + "namespace", pvc.Namespace, + "pvc", pvc.Name, + "strategy", strategy, + ) + return reconcile.Result{}, r.cleanup(ctx, pvc, strategy) + case corev1.PodFailed: + r.log.Info("PVC population importer pod failed", + "namespace", pvc.Namespace, + "pvc", pvc.Name, + "strategy", strategy, + ) + return reconcile.Result{}, nil + default: + return reconcile.Result{}, nil + } +} + +func sourceFromAnnotations(pvc *corev1.PersistentVolumeClaim, strategy string, sup supplements.Generator) *service.PVCImportSource { + switch strategy { + case service.PopulationStrategyDVCR: + secret := "" + certConfigMap := "" + if sup != nil { + secret = sup.DVCRAuthSecretForDV().Name + certConfigMap = sup.DVCRCABundleConfigMapForDV().Name + } + return service.NewPVCRegistryImportSource( + pvc.Annotations[annotations.AnnPVCPopulationSourceDVCR], + secret, + certConfigMap, + ) + case service.PopulationStrategyHostAssigned: + return service.NewPVCPVCImportSource(pvc.Annotations[annotations.AnnPVCPopulationSourcePVC], pvc.Namespace) + default: + return nil + } +} + +func (r *Reconciler) ownerAndSupplements(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (client.Object, supplements.Generator, error) { + for _, ref := range pvc.OwnerReferences { + if ref.Kind == v1alpha2.VirtualDiskKind { + vd, err := object.FetchObject(ctx, types.NamespacedName{Name: ref.Name, Namespace: pvc.Namespace}, r.client, &v1alpha2.VirtualDisk{}) + if err != nil { + return nil, nil, fmt.Errorf("fetch owner virtualdisk: %w", err) + } + if vd == nil { + return nil, nil, nil + } + return vd, supplements.NewGenerator(annotations.VDShortName, vd.Name, vd.Namespace, vd.UID), nil + } + if ref.Kind == v1alpha2.VirtualImageKind { + vi, err := object.FetchObject(ctx, types.NamespacedName{Name: ref.Name, Namespace: pvc.Namespace}, r.client, &v1alpha2.VirtualImage{}) + if err != nil { + return nil, nil, fmt.Errorf("fetch owner virtualimage: %w", err) + } + if vi == nil { + return nil, nil, nil + } + return vi, supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID), nil + } + } + return pvc, supplements.NewGenerator(pvcSupplementPrefix, pvc.Name, pvc.Namespace, pvc.UID), nil +} + +func (r *Reconciler) markDone(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { + latest := &corev1.PersistentVolumeClaim{} + if err := r.client.Get(ctx, client.ObjectKeyFromObject(pvc), latest); err != nil { + return err + } + patch := latest.DeepCopy() + if patch.Annotations == nil { + patch.Annotations = map[string]string{} + } + patch.Annotations[annotations.AnnPVCPopulationDone] = "true" + return r.client.Patch(ctx, patch, client.MergeFrom(latest)) +} + +func (r *Reconciler) cleanup(ctx context.Context, pvc *corev1.PersistentVolumeClaim, strategy string) error { + _, sup, err := r.ownerAndSupplements(ctx, pvc) + if err != nil { + return err + } + if sup != nil { + if _, err := r.pvc.Cleanup(ctx, sup, pvc); err != nil { + return err + } + } + if strategy == service.PopulationStrategySnapshot { + snapshotName := snapshotNameFromPVC(pvc) + if snapshotName != "" { + err := r.client.Delete(ctx, &vsv1.VolumeSnapshot{ObjectMeta: metav1.ObjectMeta{Name: snapshotName, Namespace: pvc.Namespace}}) + if err != nil && !k8serrors.IsNotFound(err) { + return err + } + } + } + return nil +} + +func (r *Reconciler) ensureSnapshot(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { + snapshotName := snapshotNameFromPVC(pvc) + if snapshotName == "" { + return fmt.Errorf("snapshot population pvc %s/%s has no VolumeSnapshot name", pvc.Namespace, pvc.Name) + } + + existing, err := object.FetchObject(ctx, types.NamespacedName{Name: snapshotName, Namespace: pvc.Namespace}, r.client, &vsv1.VolumeSnapshot{}) + if err != nil { + return fmt.Errorf("fetch volume snapshot: %w", err) + } + if existing != nil { + return nil + } + + sourceName := pvc.Annotations[annotations.AnnPVCPopulationSourcePVC] + if sourceName == "" { + return fmt.Errorf("snapshot population pvc %s/%s has no source pvc annotation", pvc.Namespace, pvc.Name) + } + sourceNamespace := pvc.Namespace + sourcePVC, err := object.FetchObject(ctx, types.NamespacedName{Name: sourceName, Namespace: sourceNamespace}, r.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + return fmt.Errorf("fetch source pvc: %w", err) + } + if sourcePVC == nil { + return fmt.Errorf("source pvc %s/%s not found", sourceNamespace, sourceName) + } + + snapshotClass, err := r.snapshotClassForPVC(ctx, sourcePVC) + if err != nil { + return err + } + + vs := &vsv1.VolumeSnapshot{ + TypeMeta: metav1.TypeMeta{Kind: "VolumeSnapshot", APIVersion: "snapshot.storage.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: snapshotName, + Namespace: pvc.Namespace, + OwnerReferences: []metav1.OwnerReference{service.MakeOwnerReference(sourcePVC)}, + }, + Spec: vsv1.VolumeSnapshotSpec{ + Source: vsv1.VolumeSnapshotSource{ + PersistentVolumeClaimName: ptr.To(sourcePVC.Name), + }, + VolumeSnapshotClassName: ptr.To(snapshotClass), + }, + } + if err := r.client.Create(ctx, vs); err != nil && !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("create volume snapshot: %w", err) + } + r.log.Info("PVC population snapshot created", + "namespace", pvc.Namespace, + "pvc", pvc.Name, + "snapshot", snapshotName, + ) + return nil +} + +func (r *Reconciler) snapshotClassForPVC(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (string, error) { + storageClassName := ptr.Deref(pvc.Spec.StorageClassName, "") + if storageClassName == "" { + return "", fmt.Errorf("source pvc %s/%s has no storageClassName", pvc.Namespace, pvc.Name) + } + sc, err := object.FetchObject(ctx, types.NamespacedName{Name: storageClassName}, r.client, &storagev1.StorageClass{}) + if err != nil { + return "", fmt.Errorf("fetch source storage class: %w", err) + } + if sc == nil { + return "", fmt.Errorf("source storage class %q not found", storageClassName) + } + + var list vsv1.VolumeSnapshotClassList + if err := r.client.List(ctx, &list); err != nil { + return "", fmt.Errorf("list volume snapshot classes: %w", err) + } + for _, item := range list.Items { + if item.Driver == sc.Provisioner { + return item.Name, nil + } + } + return "", fmt.Errorf("no compatible VolumeSnapshotClass found for provisioner %q", sc.Provisioner) +} + +func snapshotNameFromPVC(pvc *corev1.PersistentVolumeClaim) string { + if pvc == nil { + return "" + } + if pvc.Spec.DataSourceRef != nil && pvc.Spec.DataSourceRef.Kind == "VolumeSnapshot" { + return pvc.Spec.DataSourceRef.Name + } + if pvc.Spec.DataSource != nil && pvc.Spec.DataSource.Kind == "VolumeSnapshot" { + return pvc.Spec.DataSource.Name + } + return "" +} + +func (r *Reconciler) isWaitForFirstConsumer(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (bool, error) { + storageClassName := ptr.Deref(pvc.Spec.StorageClassName, "") + if storageClassName == "" { + return false, nil + } + sc, err := object.FetchObject(ctx, types.NamespacedName{Name: storageClassName}, r.client, &storagev1.StorageClass{}) + if err != nil { + return false, fmt.Errorf("fetch storage class: %w", err) + } + return sc != nil && sc.VolumeBindingMode != nil && *sc.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer, nil +} diff --git a/images/virtualization-artifact/pkg/controller/populator/populator_controller_test.go b/images/virtualization-artifact/pkg/controller/populator/populator_controller_test.go new file mode 100644 index 0000000000..f258fe9f78 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/populator/populator_controller_test.go @@ -0,0 +1,444 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package populator + +import ( + "context" + "testing" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestPopulatorMarksBoundCSICloneDone(t *testing.T) { + ctx := context.Background() + vd := testVD() + pvc := testTargetPVC(vd) + pvc.Annotations[annotations.AnnPVCPopulationStrategy] = service.PopulationStrategyCSIClone + pvc.Status.Phase = corev1.ClaimBound + + c := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithStatusSubresource(&corev1.PersistentVolumeClaim{}). + WithObjects(vd, pvc). + Build() + r := testReconciler(c) + + if _, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(pvc)}); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + + refreshed := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, client.ObjectKeyFromObject(pvc), refreshed); err != nil { + t.Fatalf("get pvc: %v", err) + } + if got := refreshed.Annotations[annotations.AnnPVCPopulationDone]; got != "true" { + t.Fatalf("unexpected done annotation: %q", got) + } +} + +func TestPopulatorStartsDVCRImport(t *testing.T) { + ctx := context.Background() + vd := testVD() + pvc := testTargetPVC(vd) + pvc.Annotations[annotations.AnnPVCPopulationStrategy] = service.PopulationStrategyDVCR + pvc.Annotations[annotations.AnnPVCPopulationSourceDVCR] = "docker://registry.example/disk:tag" + + c := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(vd, pvc). + Build() + r := testReconciler(c) + + result, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(pvc)}) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if !result.IsZero() { + t.Fatalf("expected no requeue while importer is pending, got %#v", result) + } + + pod := &corev1.Pod{} + if err := c.Get(ctx, types.NamespacedName{Name: "d8v-vd-pvc-importer-" + string(vd.UID), Namespace: vd.Namespace}, pod); err != nil { + t.Fatalf("expected pvc-importer pod: %v", err) + } + if len(pod.OwnerReferences) != 1 || pod.OwnerReferences[0].Kind != "PersistentVolumeClaim" || pod.OwnerReferences[0].Name != pvc.Name { + t.Fatalf("unexpected pod owner references: %#v", pod.OwnerReferences) + } + + prime := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, types.NamespacedName{Name: pvc.Name + "-prime", Namespace: pvc.Namespace}, prime); err != nil { + t.Fatalf("expected prime pvc: %v", err) + } +} + +func TestPopulatorStartsVirtualImageWFFCDVCRImportWithoutSelectedNode(t *testing.T) { + ctx := context.Background() + vi := testVI() + pvc := testVITargetPVC(vi) + pvc.Annotations[annotations.AnnPVCPopulationStrategy] = service.PopulationStrategyDVCR + pvc.Annotations[annotations.AnnPVCPopulationSourceDVCR] = "docker://registry.example/image:tag" + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "fast"}, + VolumeBindingMode: ptr.To(storagev1.VolumeBindingWaitForFirstConsumer), + } + + c := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(vi, pvc, sc). + Build() + r := testReconciler(c) + + result, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(pvc)}) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if !result.IsZero() { + t.Fatalf("expected no requeue while importer is pending, got %#v", result) + } + + pod := &corev1.Pod{} + if err := c.Get(ctx, types.NamespacedName{Name: "d8v-vi-pvc-importer-" + string(vi.UID), Namespace: vi.Namespace}, pod); err != nil { + t.Fatalf("expected pvc-importer pod: %v", err) + } +} + +func TestPopulatorStartsStandaloneDVCRImport(t *testing.T) { + ctx := context.Background() + pvc := testStandaloneTargetPVC("target", "default") + pvc.Annotations[annotations.AnnPVCPopulationStrategy] = service.PopulationStrategyDVCR + pvc.Annotations[annotations.AnnPVCPopulationSourceDVCR] = "docker://registry.example/disk:tag" + + c := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(pvc). + Build() + r := testReconciler(c) + + result, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(pvc)}) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if !result.IsZero() { + t.Fatalf("expected no requeue while importer is pending, got %#v", result) + } + + pod := &corev1.Pod{} + if err := c.Get(ctx, types.NamespacedName{Name: "d8v-pvc-pvc-importer-" + string(pvc.UID), Namespace: pvc.Namespace}, pod); err != nil { + t.Fatalf("expected standalone pvc-importer pod: %v", err) + } + if len(pod.OwnerReferences) != 1 || pod.OwnerReferences[0].Kind != "PersistentVolumeClaim" || pod.OwnerReferences[0].Name != pvc.Name { + t.Fatalf("unexpected pod owner references: %#v", pod.OwnerReferences) + } + + sup := supplements.NewGenerator(pvcSupplementPrefix, pvc.Name, pvc.Namespace, pvc.UID) + assertContainerUsesDVCRSupplement(t, pod.Spec.Containers[0], sup) +} + +func TestPopulatorStartsHostAssignedPVCCloneWithSourceAndTargetPods(t *testing.T) { + ctx := context.Background() + vd := testVD() + source := testSourcePVC("source", vd.Namespace) + source.Spec.VolumeMode = ptr.To(corev1.PersistentVolumeBlock) + target := testTargetPVC(vd) + target.Annotations[annotations.AnnPVCPopulationStrategy] = service.PopulationStrategyHostAssigned + target.Annotations[annotations.AnnPVCPopulationSourcePVC] = source.Name + target.Spec.VolumeMode = ptr.To(corev1.PersistentVolumeBlock) + + c := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(vd, source, target). + Build() + r := testReconciler(c) + sup := supplements.NewGenerator(annotations.VDShortName, vd.Name, vd.Namespace, vd.UID) + + result, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(target)}) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if !result.IsZero() { + t.Fatalf("expected no requeue while source importer is pending, got %#v", result) + } + + sourcePod := &corev1.Pod{} + if err := c.Get(ctx, sup.PVCSourceImporterPod(), sourcePod); err != nil { + t.Fatalf("expected pvc-source-importer pod: %v", err) + } + if got := sourcePod.Spec.Containers[0].Command; len(got) != 1 || got[0] != "/usr/sbin/nbdkit" { + t.Fatalf("unexpected source pod command: %#v", got) + } + targetPod := &corev1.Pod{} + if err := c.Get(ctx, sup.PVCTargetImporterPod(), targetPod); err != nil { + t.Fatalf("expected pvc-target-importer pod: %v", err) + } + container := targetPod.Spec.Containers[0] + if got := container.Command; len(got) != 1 || got[0] != "/usr/bin/pvc-target-importer" { + t.Fatalf("unexpected target pod command: %#v", got) + } + wantNBD := "nbd://" + sup.PVCSourceImporterService().Name + ":10809" + var gotOwnerUID, gotNBD string + for _, env := range container.Env { + switch env.Name { + case common.OwnerUID: + gotOwnerUID = env.Value + case common.ImporterNBDEndpoint: + gotNBD = env.Value + } + } + if gotOwnerUID != string(vd.UID) { + t.Fatalf("unexpected owner UID env: got %q, want %q", gotOwnerUID, vd.UID) + } + if gotNBD != wantNBD { + t.Fatalf("unexpected NBD endpoint env: got %q, want %q", gotNBD, wantNBD) + } + if len(container.Ports) != 1 || container.Ports[0].Name != "metrics" || container.Ports[0].ContainerPort != 8443 { + t.Fatalf("unexpected metrics port: %#v", container.Ports) + } + if err := c.Get(ctx, sup.PVCImporterPod(), &corev1.Pod{}); client.IgnoreNotFound(err) == nil && err == nil { + t.Fatalf("host-assigned clone must not create legacy pvc-importer pod") + } + if err := c.Get(ctx, types.NamespacedName{Name: target.Name + "-prime-scratch", Namespace: target.Namespace}, &corev1.PersistentVolumeClaim{}); client.IgnoreNotFound(err) == nil && err == nil { + t.Fatalf("host-assigned clone must not create scratch pvc") + } +} + +func TestPopulatorCreatesMissingSnapshot(t *testing.T) { + ctx := context.Background() + vd := testVD() + source := testSourcePVC("source", vd.Namespace) + target := testTargetPVC(vd) + target.Annotations[annotations.AnnPVCPopulationStrategy] = service.PopulationStrategySnapshot + target.Annotations[annotations.AnnPVCPopulationSourcePVC] = source.Name + target.Spec.DataSource = &corev1.TypedLocalObjectReference{APIGroup: ptr.To("snapshot.storage.k8s.io"), Kind: "VolumeSnapshot", Name: "target-snapshot"} + target.Spec.DataSourceRef = &corev1.TypedObjectReference{APIGroup: ptr.To("snapshot.storage.k8s.io"), Kind: "VolumeSnapshot", Name: "target-snapshot"} + sc := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: "fast"}, Provisioner: "csi.example.com"} + vsc := &vsv1.VolumeSnapshotClass{ObjectMeta: metav1.ObjectMeta{Name: "snap-fast"}, Driver: "csi.example.com"} + + c := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(vd, source, target, sc, vsc). + Build() + r := testReconciler(c) + + result, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(target)}) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + if !result.IsZero() { + t.Fatalf("expected no requeue while snapshot target is pending, got %#v", result) + } + + snapshot := &vsv1.VolumeSnapshot{} + if err := c.Get(ctx, types.NamespacedName{Name: "target-snapshot", Namespace: target.Namespace}, snapshot); err != nil { + t.Fatalf("expected volume snapshot: %v", err) + } + if snapshot.Spec.Source.PersistentVolumeClaimName == nil || *snapshot.Spec.Source.PersistentVolumeClaimName != source.Name { + t.Fatalf("unexpected snapshot source: %#v", snapshot.Spec.Source.PersistentVolumeClaimName) + } + if len(snapshot.OwnerReferences) != 1 || snapshot.OwnerReferences[0].Kind != "PersistentVolumeClaim" || snapshot.OwnerReferences[0].Name != source.Name { + t.Fatalf("unexpected snapshot owner references: %#v", snapshot.OwnerReferences) + } +} + +func testStandaloneTargetPVC(name, namespace string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: "55555555-5555-5555-5555-555555555555", + Annotations: map[string]string{}, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: ptr.To("fast"), + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}, + }, + } +} + +func testReconciler(c client.Client) *Reconciler { + return &Reconciler{ + client: c, + log: logger.NewLogger("error", string(logger.Discard), 0), + pvc: service.NewPersistentVolumeClaimService(c, nil, nil, service.DiskImporterConfig{ + Image: "pvc-importer:latest", + PullPolicy: string(corev1.PullIfNotPresent), + Verbose: "1", + }), + } +} + +func assertContainerUsesDVCRSupplement(t *testing.T, container corev1.Container, sup supplements.Generator) { + t.Helper() + + certName := sup.DVCRCABundleConfigMapForDV().Name + var hasCertDir bool + var hasAuthSecret bool + for _, env := range container.Env { + if env.Name == common.ImporterCertDirVar { + hasCertDir = true + } + if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == sup.DVCRAuthSecretForDV().Name { + hasAuthSecret = true + } + } + if !hasCertDir { + t.Fatalf("expected importer container to use DVCR CA configmap %q", certName) + } + if !hasAuthSecret { + t.Fatalf("expected importer container to use DVCR auth secret %q", sup.DVCRAuthSecretForDV().Name) + } +} + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + corev1.AddToScheme, + netv1.AddToScheme, + storagev1.AddToScheme, + vsv1.AddToScheme, + v1alpha2.AddToScheme, + } { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + return scheme +} + +func testVD() *v1alpha2.VirtualDisk { + return &v1alpha2.VirtualDisk{ + TypeMeta: metav1.TypeMeta{APIVersion: v1alpha2.SchemeGroupVersion.String(), Kind: v1alpha2.VirtualDiskKind}, + ObjectMeta: metav1.ObjectMeta{ + Name: "disk", + Namespace: "default", + UID: "22222222-2222-2222-2222-222222222222", + }, + } +} + +func testVI() *v1alpha2.VirtualImage { + return &v1alpha2.VirtualImage{ + TypeMeta: metav1.TypeMeta{APIVersion: v1alpha2.SchemeGroupVersion.String(), Kind: v1alpha2.VirtualImageKind}, + ObjectMeta: metav1.ObjectMeta{ + Name: "image", + Namespace: "default", + UID: "66666666-6666-6666-6666-666666666666", + }, + } +} + +func testTargetPVC(vd *v1alpha2.VirtualDisk) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "target", + Namespace: vd.Namespace, + UID: "33333333-3333-3333-3333-333333333333", + Annotations: map[string]string{}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.VirtualDiskKind, + Name: vd.Name, + UID: vd.UID, + Controller: ptr.To(true), + }}, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: ptr.To("fast"), + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}, + }, + } +} + +func testVITargetPVC(vi *v1alpha2.VirtualImage) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "target", + Namespace: vi.Namespace, + UID: "77777777-7777-7777-7777-777777777777", + Annotations: map[string]string{}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.VirtualImageKind, + Name: vi.Name, + UID: vi.UID, + Controller: ptr.To(true), + }}, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: ptr.To("fast"), + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}, + }, + } +} + +func testSourcePVC(name, namespace string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: "44444444-4444-4444-4444-444444444444", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: ptr.To("fast"), + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}, + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + } +} diff --git a/images/virtualization-artifact/pkg/controller/populator/populator_logging.go b/images/virtualization-artifact/pkg/controller/populator/populator_logging.go new file mode 100644 index 0000000000..61ce824895 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/populator/populator_logging.go @@ -0,0 +1,140 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package populator + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" +) + +type importerPodSnapshot struct { + role string + name string + phase corev1.PodPhase +} + +func (r *Reconciler) logImporterPodTransitions( + pvc *corev1.PersistentVolumeClaim, + strategy string, + before, after []importerPodSnapshot, +) { + afterByRole := make(map[string]importerPodSnapshot, len(after)) + for _, snap := range after { + afterByRole[snap.role] = snap + } + + for _, prev := range before { + next, ok := afterByRole[prev.role] + if !ok { + continue + } + if prev.phase == "" && next.phase != "" { + r.log.Info("PVC population importer pod created", + "namespace", pvc.Namespace, + "pvc", pvc.Name, + "strategy", strategy, + "podRole", next.role, + "pod", next.name, + "phase", string(next.phase), + ) + } + if prev.phase != "" && prev.phase != corev1.PodSucceeded && next.phase == corev1.PodSucceeded { + r.log.Info("PVC population importer pod finished", + "namespace", pvc.Namespace, + "pvc", pvc.Name, + "strategy", strategy, + "podRole", next.role, + "pod", next.name, + ) + } + } +} + +func (r *Reconciler) rebindPending(ctx context.Context, pvc *corev1.PersistentVolumeClaim, sup supplements.Generator, strategy string) bool { + if pvc.Annotations[annotations.AnnPVCPopulationDone] == "true" { + return false + } + + snapshots, err := r.importerPodSnapshots(ctx, sup, strategy) + if err != nil { + return false + } + + switch strategy { + case service.PopulationStrategyHostAssigned: + for _, snap := range snapshots { + if snap.role == "target-importer" && snap.phase == corev1.PodSucceeded { + return true + } + } + case service.PopulationStrategyDVCR: + for _, snap := range snapshots { + if snap.role == "importer" && snap.phase == corev1.PodSucceeded { + return true + } + } + } + return false +} + +func (r *Reconciler) importerPodSnapshots(ctx context.Context, sup supplements.Generator, strategy string) ([]importerPodSnapshot, error) { + var keys []struct { + role string + key types.NamespacedName + } + + switch strategy { + case service.PopulationStrategyHostAssigned: + keys = append(keys, + struct { + role string + key types.NamespacedName + }{"source-importer", sup.PVCSourceImporterPod()}, + struct { + role string + key types.NamespacedName + }{"target-importer", sup.PVCTargetImporterPod()}, + ) + case service.PopulationStrategyDVCR: + keys = append(keys, struct { + role string + key types.NamespacedName + }{"importer", sup.PVCImporterPod()}) + } + + snapshots := make([]importerPodSnapshot, 0, len(keys)) + for _, item := range keys { + pod, err := object.FetchObject(ctx, item.key, r.client, &corev1.Pod{}) + if err != nil { + return nil, fmt.Errorf("fetch %s pod: %w", item.role, err) + } + snap := importerPodSnapshot{role: item.role, name: item.key.Name} + if pod != nil { + snap.phase = pod.Status.Phase + } + snapshots = append(snapshots, snap) + } + return snapshots, nil +} diff --git a/images/virtualization-artifact/pkg/controller/populator/populator_watches.go b/images/virtualization-artifact/pkg/controller/populator/populator_watches.go new file mode 100644 index 0000000000..fc5000e0a2 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/populator/populator_watches.go @@ -0,0 +1,230 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package populator + +import ( + "context" + "fmt" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" +) + +func addWatches(mgr manager.Manager, ctr controller.Controller) error { + if err := ctr.Watch( + source.Kind(mgr.GetCache(), &corev1.PersistentVolumeClaim{}, + &handler.TypedEnqueueRequestForObject[*corev1.PersistentVolumeClaim]{}, + predicate.TypedFuncs[*corev1.PersistentVolumeClaim]{ + CreateFunc: func(e event.TypedCreateEvent[*corev1.PersistentVolumeClaim]) bool { + return hasPopulationStrategy(e.Object) + }, + UpdateFunc: func(e event.TypedUpdateEvent[*corev1.PersistentVolumeClaim]) bool { + if !hasPopulationStrategy(e.ObjectNew) { + return false + } + return e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase || + e.ObjectOld.Spec.VolumeName != e.ObjectNew.Spec.VolumeName || + e.ObjectOld.Annotations[service.SelectedNodeAnnotation] != e.ObjectNew.Annotations[service.SelectedNodeAnnotation] + }, + DeleteFunc: func(e event.TypedDeleteEvent[*corev1.PersistentVolumeClaim]) bool { + return hasPopulationStrategy(e.Object) + }, + }, + ), + ); err != nil { + return fmt.Errorf("watch population target PVCs: %w", err) + } + + if err := ctr.Watch( + source.Kind(mgr.GetCache(), &corev1.PersistentVolumeClaim{}, + handler.TypedEnqueueRequestsFromMapFunc(enqueuePopulationTargetFromHelperPVC(mgr.GetClient())), + predicate.TypedFuncs[*corev1.PersistentVolumeClaim]{ + CreateFunc: func(e event.TypedCreateEvent[*corev1.PersistentVolumeClaim]) bool { + return isPopulationHelperPVC(e.Object) + }, + UpdateFunc: func(e event.TypedUpdateEvent[*corev1.PersistentVolumeClaim]) bool { + if !isPopulationHelperPVC(e.ObjectNew) { + return false + } + return e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase || + e.ObjectOld.Spec.VolumeName != e.ObjectNew.Spec.VolumeName + }, + DeleteFunc: func(e event.TypedDeleteEvent[*corev1.PersistentVolumeClaim]) bool { + return isPopulationHelperPVC(e.Object) + }, + }, + ), + ); err != nil { + return fmt.Errorf("watch population helper PVCs: %w", err) + } + + if err := ctr.Watch( + source.Kind(mgr.GetCache(), &corev1.Pod{}, + handler.TypedEnqueueRequestsFromMapFunc(enqueuePopulationTargetFromImporterPod(mgr.GetClient())), + predicate.TypedFuncs[*corev1.Pod]{ + CreateFunc: func(e event.TypedCreateEvent[*corev1.Pod]) bool { + return isImporterPod(e.Object) + }, + UpdateFunc: func(e event.TypedUpdateEvent[*corev1.Pod]) bool { + return isImporterPod(e.ObjectNew) && e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase + }, + DeleteFunc: func(e event.TypedDeleteEvent[*corev1.Pod]) bool { return false }, + }, + ), + ); err != nil { + return fmt.Errorf("watch importer pods: %w", err) + } + + if err := ctr.Watch( + source.Kind(mgr.GetCache(), &vsv1.VolumeSnapshot{}, + handler.TypedEnqueueRequestsFromMapFunc(enqueuePopulationTargetFromVolumeSnapshot(mgr.GetClient())), + predicate.TypedFuncs[*vsv1.VolumeSnapshot]{ + CreateFunc: func(e event.TypedCreateEvent[*vsv1.VolumeSnapshot]) bool { return true }, + UpdateFunc: func(e event.TypedUpdateEvent[*vsv1.VolumeSnapshot]) bool { + return volumeSnapshotReadyToUse(e.ObjectOld) != volumeSnapshotReadyToUse(e.ObjectNew) || + (e.ObjectOld.Status != nil && e.ObjectNew.Status != nil && + e.ObjectOld.Status.BoundVolumeSnapshotContentName != e.ObjectNew.Status.BoundVolumeSnapshotContentName) + }, + DeleteFunc: func(e event.TypedDeleteEvent[*vsv1.VolumeSnapshot]) bool { return false }, + }, + ), + ); err != nil { + return fmt.Errorf("watch volume snapshots: %w", err) + } + + return nil +} + +func isPopulationHelperPVC(pvc *corev1.PersistentVolumeClaim) bool { + if pvc == nil || hasPopulationStrategy(pvc) { + return false + } + for _, ref := range pvc.OwnerReferences { + if ref.Kind == "PersistentVolumeClaim" { + return true + } + } + return false +} + +func isImporterPod(pod *corev1.Pod) bool { + if pod == nil { + return false + } + if pod.Labels[annotations.AppLabel] != annotations.CDILabelValue { + return false + } + for _, ref := range pod.OwnerReferences { + if ref.Kind == "PersistentVolumeClaim" { + return true + } + } + return false +} + +func enqueuePopulationTargetFromHelperPVC(c client.Client) func(context.Context, *corev1.PersistentVolumeClaim) []reconcile.Request { + return func(ctx context.Context, pvc *corev1.PersistentVolumeClaim) []reconcile.Request { + return populationTargetRequests(pvc.Namespace, helperPopulationTargetName(ctx, c, pvc)) + } +} + +func helperPopulationTargetName(ctx context.Context, c client.Client, helper *corev1.PersistentVolumeClaim) string { + for _, ref := range helper.OwnerReferences { + if ref.Kind != "PersistentVolumeClaim" { + continue + } + owner, err := object.FetchObject(ctx, types.NamespacedName{Name: ref.Name, Namespace: helper.Namespace}, c, &corev1.PersistentVolumeClaim{}) + if err != nil || owner == nil || !hasPopulationStrategy(owner) { + continue + } + return owner.Name + } + return "" +} + +func enqueuePopulationTargetFromImporterPod(c client.Client) func(context.Context, *corev1.Pod) []reconcile.Request { + return func(ctx context.Context, pod *corev1.Pod) []reconcile.Request { + for _, ref := range pod.OwnerReferences { + if ref.Kind != "PersistentVolumeClaim" { + continue + } + target, err := object.FetchObject(ctx, types.NamespacedName{Name: ref.Name, Namespace: pod.Namespace}, c, &corev1.PersistentVolumeClaim{}) + if err != nil || target == nil || !hasPopulationStrategy(target) { + continue + } + return populationTargetRequests(target.Namespace, target.Name) + } + return nil + } +} + +func enqueuePopulationTargetFromVolumeSnapshot(c client.Client) func(context.Context, *vsv1.VolumeSnapshot) []reconcile.Request { + return func(ctx context.Context, vs *vsv1.VolumeSnapshot) []reconcile.Request { + var pvcList corev1.PersistentVolumeClaimList + if err := c.List(ctx, &pvcList, client.InNamespace(vs.Namespace)); err != nil { + return nil + } + + var requests []reconcile.Request + seen := make(map[string]struct{}) + for i := range pvcList.Items { + pvc := &pvcList.Items[i] + if !hasPopulationStrategy(pvc) { + continue + } + if pvc.Annotations[annotations.AnnPVCPopulationStrategy] != service.PopulationStrategySnapshot { + continue + } + if snapshotNameFromPVC(pvc) != vs.Name { + continue + } + if _, ok := seen[pvc.Name]; ok { + continue + } + seen[pvc.Name] = struct{}{} + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(pvc)}) + } + return requests + } +} + +func populationTargetRequests(namespace, name string) []reconcile.Request { + if name == "" { + return nil + } + return []reconcile.Request{{NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}}} +} + +func volumeSnapshotReadyToUse(vs *vsv1.VolumeSnapshot) bool { + if vs == nil || vs.Status == nil || vs.Status.ReadyToUse == nil { + return false + } + return *vs.Status.ReadyToUse +} diff --git a/images/virtualization-artifact/pkg/controller/service/condition.go b/images/virtualization-artifact/pkg/controller/service/condition.go index 6cc581e545..089a0272bc 100644 --- a/images/virtualization-artifact/pkg/controller/service/condition.go +++ b/images/virtualization-artifact/pkg/controller/service/condition.go @@ -21,7 +21,6 @@ import ( corev1 "k8s.io/api/core/v1" virtv1 "kubevirt.io/api/core/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" ) func CapitalizeFirstLetter(s string) string { @@ -36,15 +35,6 @@ func CapitalizeFirstLetter(s string) string { return string(runes) } -func GetDataVolumeCondition(conditionType cdiv1.DataVolumeConditionType, conditions []cdiv1.DataVolumeCondition) *cdiv1.DataVolumeCondition { - for i, condition := range conditions { - if condition.Type == conditionType { - return &conditions[i] - } - } - return nil -} - func GetPersistentVolumeClaimCondition(conditionType corev1.PersistentVolumeClaimConditionType, conditions []corev1.PersistentVolumeClaimCondition) *corev1.PersistentVolumeClaimCondition { for i, condition := range conditions { if condition.Type == conditionType { diff --git a/images/virtualization-artifact/pkg/controller/service/disk_import_service.go b/images/virtualization-artifact/pkg/controller/service/disk_import_service.go new file mode 100644 index 0000000000..34a44e1345 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/disk_import_service.go @@ -0,0 +1,85 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// PVCImportSource describes where a target PersistentVolumeClaim should pull +// its data from. Either Registry or PVC may be set; both nil is valid for blank +// PVCs that do not need any pre-population. +type PVCImportSource struct { + Registry *PVCImportSourceRegistry + PVC *PVCImportSourcePVC +} + +// PVCImportSourceRegistry points at a DVCR registry image populated by an +// upstream uploader/importer pod. +type PVCImportSourceRegistry struct { + URL string + Secret string + CertConfigMap string +} + +// PVCImportSourcePVC points at another PersistentVolumeClaim used as the +// clone source. +type PVCImportSourcePVC struct { + Name string + Namespace string +} + +// NewPVCRegistryImportSource builds a PVCImportSource that points at a DVCR +// registry image (used by Upload, HTTP, Registry and ObjectRef CVI/VI data +// sources). +func NewPVCRegistryImportSource(url, secret, certConfigMap string) *PVCImportSource { + return &PVCImportSource{ + Registry: &PVCImportSourceRegistry{ + URL: url, + Secret: secret, + CertConfigMap: certConfigMap, + }, + } +} + +// NewPVCPVCImportSource builds a PVCImportSource that points at another PVC +// (used when cloning from a VirtualDisk). +func NewPVCPVCImportSource(name, namespace string) *PVCImportSource { + return &PVCImportSource{ + PVC: &PVCImportSourcePVC{ + Name: name, + Namespace: namespace, + }, + } +} + +// ownerReferenceForObject builds a controller OwnerReference pointing at +// owner. It is used by services that create child resources (PVCs, snapshots) +// owned by a VirtualDisk or VirtualImage. +func ownerReferenceForObject(obj client.Object) metav1.OwnerReference { + gvk := obj.GetObjectKind().GroupVersionKind() + return metav1.OwnerReference{ + APIVersion: gvk.GroupVersion().String(), + Kind: gvk.Kind, + Name: obj.GetName(), + UID: obj.GetUID(), + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + } +} diff --git a/images/virtualization-artifact/pkg/controller/service/disk_service.go b/images/virtualization-artifact/pkg/controller/service/disk_service.go index eae8af0931..22dc17c2df 100644 --- a/images/virtualization-artifact/pkg/controller/service/disk_service.go +++ b/images/virtualization-artifact/pkg/controller/service/disk_service.go @@ -20,9 +20,7 @@ import ( "context" "errors" "fmt" - "slices" "strconv" - "strings" vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" corev1 "k8s.io/api/core/v1" @@ -36,11 +34,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" - dvutil "github.com/deckhouse/virtualization-controller/pkg/common/datavolume" networkpolicy "github.com/deckhouse/virtualization-controller/pkg/common/network_policy" "github.com/deckhouse/virtualization-controller/pkg/common/object" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/kvbuilder" "github.com/deckhouse/virtualization-controller/pkg/controller/service/volumemode" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" "github.com/deckhouse/virtualization-controller/pkg/dvcr" @@ -52,118 +48,66 @@ type DiskService struct { dvcrSettings *dvcr.Settings protection *ProtectionService controllerName string + pvc *PersistentVolumeClaimService volumeAndAccessModesGetter volumemode.VolumeAndAccessModesGetter } +type DiskImporterConfig struct { + Image string + ResourceRequirements corev1.ResourceRequirements + PullPolicy string + Verbose string +} + func NewDiskService( client client.Client, dvcrSettings *dvcr.Settings, protection *ProtectionService, controllerName string, + diskImporterConfig ...DiskImporterConfig, ) *DiskService { + var cfg DiskImporterConfig + if len(diskImporterConfig) > 0 { + cfg = diskImporterConfig[0] + } + return &DiskService{ client: client, dvcrSettings: dvcrSettings, protection: protection, controllerName: controllerName, + pvc: NewPersistentVolumeClaimService(client, dvcrSettings, protection, cfg), volumeAndAccessModesGetter: volumemode.NewVolumeAndAccessModesGetter(client, nil), } } -func (s DiskService) Start( - ctx context.Context, - pvcSize resource.Quantity, - sc *storagev1.StorageClass, - source *cdiv1.DataVolumeSource, - obj client.Object, - sup supplements.DataVolumeSupplement, - opts ...Option, -) error { - if sc == nil { - return errors.New("cannot create DataVolume: StorageClass must not be nil") - } - - options := newGenericOptions(opts...) - - dvBuilder := kvbuilder.NewDV(sup.DataVolume()) - dvBuilder.SetDataSource(source) - dvBuilder.SetOwnerRef(obj, obj.GetObjectKind().GroupVersionKind()) - - if options.nodePlacement != nil { - err := dvBuilder.SetNodePlacement(options.nodePlacement) - if err != nil { - return fmt.Errorf("set node placement: %w", err) - } - } - - volumeMode, accessMode, err := s.GetVolumeAndAccessModes(ctx, obj, sc) - if err != nil { - return fmt.Errorf("get volume and access modes: %w", err) - } - - dvBuilder.SetPVC(&sc.Name, pvcSize, accessMode, volumeMode) - - if s.isImmediateBindingMode(sc) { - dvBuilder.SetImmediate() - } - - dv := dvBuilder.GetResource() - err = s.client.Create(ctx, dv) - if err != nil && !k8serrors.IsAlreadyExists(err) { - return err - } - - err = networkpolicy.CreateNetworkPolicy(ctx, s.client, dv, sup, s.protection.GetFinalizer()) - if err != nil { - return fmt.Errorf("failed to create NetworkPolicy: %w", err) - } - - if source.Blank != nil || source.PVC != nil { - return nil - } +func (s DiskService) Client() client.Client { + return s.client +} - return supplements.EnsureForDataVolume(ctx, s.client, sup, dvBuilder.GetResource(), s.dvcrSettings) +// PersistentVolumeClaim returns the PersistentVolumeClaimService that this +// DiskService delegates target-PVC creation and import orchestration to. +// Steps and data sources should call PersistentVolumeClaim().Import to drive +// a target PVC towards Bound + populated state instead of orchestrating +// scratch PVCs / pvc-importer pods themselves. +func (s DiskService) PersistentVolumeClaim() *PersistentVolumeClaimService { + return s.pvc } func (s DiskService) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { return s.volumeAndAccessModesGetter.GetVolumeAndAccessModes(ctx, obj, sc) } -func (s DiskService) StartImmediate( - ctx context.Context, - pvcSize resource.Quantity, - sc *storagev1.StorageClass, - source *cdiv1.DataVolumeSource, - obj client.Object, - dataVolumeSupplement supplements.DataVolumeSupplement, -) error { - if sc == nil { - return errors.New("cannot create DataVolume: StorageClass must not be nil") +// GetPVCImporterPod returns the pod that writes into the prime PVC, or nil when +// it has not been created yet. Host-assigned PVC-to-PVC clones use the +// pvc-target-importer pod; DVCR imports keep the legacy pvc-importer name. +func (s DiskService) GetPVCImporterPod(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) { + pod, err := object.FetchObject(ctx, sup.PVCTargetImporterPod(), s.client, &corev1.Pod{}) + if err != nil || pod != nil { + return pod, err } - - dvBuilder := kvbuilder.NewDV(dataVolumeSupplement.DataVolume()) - dvBuilder.SetDataSource(source) - dvBuilder.SetOwnerRef(obj, obj.GetObjectKind().GroupVersionKind()) - dvBuilder.SetPVC(ptr.To(sc.GetName()), pvcSize, corev1.ReadWriteMany, corev1.PersistentVolumeBlock) - dvBuilder.SetImmediate() - dv := dvBuilder.GetResource() - - err := s.client.Create(ctx, dv) - if err != nil && !k8serrors.IsAlreadyExists(err) { - return err - } - - err = networkpolicy.CreateNetworkPolicy(ctx, s.client, dv, dataVolumeSupplement, s.protection.GetFinalizer()) - if err != nil { - return fmt.Errorf("failed to create NetworkPolicy: %w", err) - } - - if source.PVC != nil { - return nil - } - - return supplements.EnsureForDataVolume(ctx, s.client, dataVolumeSupplement, dvBuilder.GetResource(), s.dvcrSettings) + return object.FetchObject(ctx, sup.PVCImporterPod(), s.client, &corev1.Pod{}) } func (s DiskService) CheckProvisioning(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { @@ -183,7 +127,7 @@ func (s DiskService) CheckProvisioning(ctx context.Context, pvc *corev1.Persiste pod, err := object.FetchObject(ctx, types.NamespacedName{Name: podName, Namespace: pvc.Namespace}, s.client, &corev1.Pod{}) if err != nil { - return fmt.Errorf("failed to fetch data volume provisioner %s: %w", podName, err) + return fmt.Errorf("failed to fetch pvc provisioner %s: %w", podName, err) } if pod == nil { @@ -192,7 +136,7 @@ func (s DiskService) CheckProvisioning(ctx context.Context, pvc *corev1.Persiste scheduled, _ := conditions.GetPodCondition(corev1.PodScheduled, pod.Status.Conditions) if scheduled.Status == corev1.ConditionFalse && scheduled.Reason == corev1.PodReasonUnschedulable { - return ErrDataVolumeProvisionerUnschedulable + return ErrProvisionerUnschedulable } return nil @@ -238,28 +182,18 @@ func (s DiskService) CleanUp(ctx context.Context, sup supplements.Generator) (bo } func (s DiskService) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { - // 1. Update owner ref of pvc. - pvc, err := s.GetPersistentVolumeClaim(ctx, sup) - if err != nil { - return false, err + target := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: sup.PersistentVolumeClaim().Name, + Namespace: sup.PersistentVolumeClaim().Namespace, + }, } - - if pvc != nil { - ownerReferences := slices.DeleteFunc(pvc.OwnerReferences, func(ref metav1.OwnerReference) bool { - return ref.Kind == "DataVolume" - }) - - if len(pvc.OwnerReferences) != len(ownerReferences) { - pvc.OwnerReferences = ownerReferences - err = s.client.Update(ctx, pvc) - if err != nil && !k8serrors.IsNotFound(err) { - return false, fmt.Errorf("update owner ref of pvc: %w", err) - } - } + importSupplementsDeleted, err := s.pvc.Cleanup(ctx, sup, target) + if err != nil { + return false, fmt.Errorf("delete pvc import supplements: %w", err) } - // 2. Delete network policy. - networkPolicy, err := networkpolicy.GetNetworkPolicy(ctx, s.client, sup.LegacyDataVolume(), sup) + networkPolicy, err := networkpolicy.GetNetworkPolicy(ctx, s.client, sup.LegacyCommonResourceName(), sup) if err != nil { return false, err } @@ -276,78 +210,10 @@ func (s DiskService) CleanUpSupplements(ctx context.Context, sup supplements.Gen } } - // 3. Delete DataVolume. - var hasDeleted bool - dv, err := s.GetDataVolume(ctx, sup) - if err != nil { - return false, fmt.Errorf("get dv: %w", err) - } - - if dv != nil { - err = s.protection.RemoveProtection(ctx, dv) - if err != nil { - return false, fmt.Errorf("remove protection from dv: %w", err) - } - - err = s.client.Delete(ctx, dv) - if err != nil && !k8serrors.IsNotFound(err) { - return false, fmt.Errorf("delete dv: %w", err) - } - - hasDeleted = true - } - - return hasDeleted, supplements.CleanupForDataVolume(ctx, s.client, sup, s.dvcrSettings) + return importSupplementsDeleted || networkPolicy != nil, nil } -func (s DiskService) Protect(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { - err := s.protection.AddOwnerRef(ctx, owner, pvc) - if err != nil { - return fmt.Errorf("failed to add owner ref for pvc: %w", err) - } - - err = s.protection.AddProtection(ctx, dv, pvc) - if err != nil { - return fmt.Errorf("failed to add protection for disk's supplements: %w", err) - } - - if dv != nil { - networkPolicy, err := networkpolicy.GetNetworkPolicyFromObject(ctx, s.client, dv, sup) - if err != nil { - return fmt.Errorf("failed to get networkPolicy for disk's supplements protection: %w", err) - } - - if networkPolicy != nil { - err = s.protection.AddProtection(ctx, networkPolicy) - if err != nil { - return fmt.Errorf("failed to remove protection for disk's supplements: %w", err) - } - } - } - - return nil -} - -func (s DiskService) Unprotect(ctx context.Context, sup supplements.Generator, dv *cdiv1.DataVolume) error { - err := s.protection.RemoveProtection(ctx, dv) - if err != nil { - return fmt.Errorf("failed to remove protection for disk's supplements: %w", err) - } - - if dv != nil { - networkPolicy, err := networkpolicy.GetNetworkPolicyFromObject(ctx, s.client, dv, sup) - if err != nil { - return fmt.Errorf("failed to get networkPolicy for removing disk's supplements protection: %w", err) - } - - if networkPolicy != nil { - err = s.protection.RemoveProtection(ctx, networkPolicy) - if err != nil { - return fmt.Errorf("failed to remove protection for disk's supplements: %w", err) - } - } - } - +func (s DiskService) Unprotect(_ context.Context, _ supplements.Generator) error { return nil } @@ -373,27 +239,6 @@ func (s DiskService) Resize(ctx context.Context, pvc *corev1.PersistentVolumeCla return nil } -func (s DiskService) IsImportDone(dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) bool { - return dv != nil && dv.Status.Phase == cdiv1.Succeeded && pvc != nil && pvc.Status.Phase == corev1.ClaimBound -} - -func (s DiskService) GetProgress(dv *cdiv1.DataVolume, prevProgress string, opts ...GetProgressOption) string { - if dv == nil { - return prevProgress - } - - dvProgress := string(dv.Status.Progress) - if dvProgress != "N/A" && dvProgress != "" { - for _, o := range opts { - dvProgress = o.Apply(dvProgress) - } - - return dvProgress - } - - return prevProgress -} - func (s DiskService) GetCapacity(pvc *corev1.PersistentVolumeClaim) string { if pvc != nil && pvc.Status.Phase == corev1.ClaimBound { return ptr.To(pvc.Status.Capacity[corev1.ResourceStorage]).String() @@ -406,21 +251,10 @@ func (s DiskService) GetStorageProfile(ctx context.Context, name string) (*cdiv1 return object.FetchObject(ctx, types.NamespacedName{Name: name}, s.client, &cdiv1.StorageProfile{}) } -func (s DiskService) isImmediateBindingMode(sc *storagev1.StorageClass) bool { - if sc == nil { - return false - } - return sc.GetAnnotations()[annotations.AnnVirtualDiskBindingMode] == string(storagev1.VolumeBindingImmediate) -} - func (s DiskService) GetStorageClass(ctx context.Context, scName string) (*storagev1.StorageClass, error) { return object.FetchObject(ctx, types.NamespacedName{Name: scName}, s.client, &storagev1.StorageClass{}) } -func (s DiskService) GetDataVolume(ctx context.Context, sup supplements.Generator) (*cdiv1.DataVolume, error) { - return supplements.FetchSupplement(ctx, s.client, sup, supplements.SupplementDataVolume, &cdiv1.DataVolume{}) -} - func (s DiskService) GetPersistentVolumeClaim(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) { return supplements.FetchSupplement(ctx, s.client, sup, supplements.SupplementPVC, &corev1.PersistentVolumeClaim{}) } @@ -453,56 +287,43 @@ func (s DiskService) GetVirtualDiskSnapshot(ctx context.Context, name, namespace return object.FetchObject(ctx, types.NamespacedName{Name: name, Namespace: namespace}, s.client, &v1alpha2.VirtualDiskSnapshot{}) } -func (s DiskService) CheckImportProcess(ctx context.Context, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { - if dv == nil { - return nil - } - - dvRunning := GetDataVolumeCondition(cdiv1.DataVolumeRunning, dv.Status.Conditions) - if dvRunning == nil || dvRunning.Status != corev1.ConditionFalse { - return nil - } - - if strings.Contains(dvRunning.Reason, "Error") { - return fmt.Errorf("%w: %s", ErrDataVolumeNotRunning, dvRunning.Message) - } - - if pvc == nil { - return nil - } - - key := types.NamespacedName{ - Namespace: dv.Namespace, - Name: dvutil.GetImporterPrimeName(pvc.UID), - } - - cdiImporterPrime, err := object.FetchObject(ctx, key, s.client, &corev1.Pod{}) - if err != nil { - return err - } - - if cdiImporterPrime != nil { - podInitializedCond, ok := conditions.GetPodCondition(corev1.PodInitialized, cdiImporterPrime.Status.Conditions) - if ok && podInitializedCond.Status == corev1.ConditionFalse && strings.Contains(podInitializedCond.Reason, "Error") { - return fmt.Errorf("%w; %s error %s: %s", ErrDataVolumeNotRunning, key.String(), podInitializedCond.Reason, podInitializedCond.Message) - } - - podScheduledCond, ok := conditions.GetPodCondition(corev1.PodScheduled, cdiImporterPrime.Status.Conditions) - if ok && podScheduledCond.Status == corev1.ConditionFalse && strings.Contains(podScheduledCond.Reason, "Error") { - return fmt.Errorf("%w; %s error %s: %s", ErrDataVolumeNotRunning, key.String(), podScheduledCond.Reason, podScheduledCond.Message) - } - } +var ErrInsufficientPVCSize = errors.New("the specified pvc size is insufficient") - return nil +// DefaultAlignBlockSize is the alignment used for image-derived volume sizes. +// It mirrors CDI's util.DefaultAlignBlockSize (1 MiB), a multiple of all known +// hardware block sizes (512/4k/8k/32k/64k). +const DefaultAlignBlockSize int64 = 1024 * 1024 + +// alignUpToBlockSize rounds size up to the next multiple of DefaultAlignBlockSize. +// +// qemu-img convert aligns the produced image's virtual size up to a block +// boundary, so the converted image can end up slightly larger than the raw size +// reported for the source. Sizing the target volume from the unaligned size can +// therefore leave it a few bytes too small and make the importer fail with +// "virtual image size ... is larger than the reported available storage". CDI +// solves this by aligning the required space the same way (see +// GetRequiredSpace), and we mirror it here. +func alignUpToBlockSize(size int64) int64 { + if size <= 0 { + return size + } + blocks := (size + DefaultAlignBlockSize - 1) / DefaultAlignBlockSize + return blocks * DefaultAlignBlockSize } -var ErrInsufficientPVCSize = errors.New("the specified pvc size is insufficient") - func GetValidatedPVCSize(pvcSize *resource.Quantity, requiredSize resource.Quantity) (resource.Quantity, error) { if requiredSize.IsZero() { return resource.Quantity{}, errors.New("got zero size from data source, please report a bug") } + // Align the image-derived required size up to the block boundary to absorb + // the size growth that qemu-img convert introduces during provisioning. Keep + // the original quantity untouched when it is already aligned to preserve its + // representation. + if aligned := alignUpToBlockSize(requiredSize.Value()); aligned != requiredSize.Value() { + requiredSize = *resource.NewQuantity(aligned, resource.BinarySI) + } + if pvcSize == nil { return requiredSize, nil } diff --git a/images/virtualization-artifact/pkg/controller/service/errors.go b/images/virtualization-artifact/pkg/controller/service/errors.go index 7842a7f31d..c08e91da65 100644 --- a/images/virtualization-artifact/pkg/controller/service/errors.go +++ b/images/virtualization-artifact/pkg/controller/service/errors.go @@ -22,9 +22,9 @@ import ( ) var ( - ErrDefaultStorageClassNotFound = errors.New("default storage class not found") - ErrDataVolumeNotRunning = errors.New("pvc importer is not running") - ErrDataVolumeProvisionerUnschedulable = errors.New("provisioner unschedulable") + ErrDefaultStorageClassNotFound = errors.New("default storage class not found") + ErrImporterNotRunning = errors.New("pvc importer is not running") + ErrProvisionerUnschedulable = errors.New("provisioner unschedulable") ) type NoSizingPolicyMatchError struct { diff --git a/images/virtualization-artifact/pkg/controller/service/mock.go b/images/virtualization-artifact/pkg/controller/service/mock.go index 9f01b376f8..4562594f29 100644 --- a/images/virtualization-artifact/pkg/controller/service/mock.go +++ b/images/virtualization-artifact/pkg/controller/service/mock.go @@ -925,6 +925,9 @@ var _ VirtClient = &VirtClientMock{} // USBDevicesFunc: func(namespace string) corev1alpha2.USBDeviceInterface { // panic("mock out the USBDevices method") // }, +// VirtualDiskSnapshotsFunc: func(namespace string) corev1alpha2.VirtualDiskSnapshotInterface { +// panic("mock out the VirtualDiskSnapshots method") +// }, // VirtualDisksFunc: func(namespace string) corev1alpha2.VirtualDiskInterface { // panic("mock out the VirtualDisks method") // }, @@ -1139,6 +1142,9 @@ type VirtClientMock struct { // USBDevicesFunc mocks the USBDevices method. USBDevicesFunc func(namespace string) corev1alpha2.USBDeviceInterface + // VirtualDiskSnapshotsFunc mocks the VirtualDiskSnapshots method. + VirtualDiskSnapshotsFunc func(namespace string) corev1alpha2.VirtualDiskSnapshotInterface + // VirtualDisksFunc mocks the VirtualDisks method. VirtualDisksFunc func(namespace string) corev1alpha2.VirtualDiskInterface @@ -1350,6 +1356,11 @@ type VirtClientMock struct { // Namespace is the namespace argument value. Namespace string } + // VirtualDiskSnapshots holds details about calls to the VirtualDiskSnapshots method. + VirtualDiskSnapshots []struct { + // Namespace is the namespace argument value. + Namespace string + } // VirtualDisks holds details about calls to the VirtualDisks method. VirtualDisks []struct { // Namespace is the namespace argument value. @@ -1454,6 +1465,7 @@ type VirtClientMock struct { lockStorageV1beta1 sync.RWMutex lockStoragemigrationV1alpha1 sync.RWMutex lockUSBDevices sync.RWMutex + lockVirtualDiskSnapshots sync.RWMutex lockVirtualDisks sync.RWMutex lockVirtualImages sync.RWMutex lockVirtualMachineBlockDeviceAttachments sync.RWMutex @@ -3064,6 +3076,38 @@ func (mock *VirtClientMock) USBDevicesCalls() []struct { return calls } +// VirtualDiskSnapshots calls VirtualDiskSnapshotsFunc. +func (mock *VirtClientMock) VirtualDiskSnapshots(namespace string) corev1alpha2.VirtualDiskSnapshotInterface { + if mock.VirtualDiskSnapshotsFunc == nil { + panic("VirtClientMock.VirtualDiskSnapshotsFunc: method is nil but VirtClient.VirtualDiskSnapshots was just called") + } + callInfo := struct { + Namespace string + }{ + Namespace: namespace, + } + mock.lockVirtualDiskSnapshots.Lock() + mock.calls.VirtualDiskSnapshots = append(mock.calls.VirtualDiskSnapshots, callInfo) + mock.lockVirtualDiskSnapshots.Unlock() + return mock.VirtualDiskSnapshotsFunc(namespace) +} + +// VirtualDiskSnapshotsCalls gets all the calls that were made to VirtualDiskSnapshots. +// Check the length with: +// +// len(mockedVirtClient.VirtualDiskSnapshotsCalls()) +func (mock *VirtClientMock) VirtualDiskSnapshotsCalls() []struct { + Namespace string +} { + var calls []struct { + Namespace string + } + mock.lockVirtualDiskSnapshots.RLock() + calls = mock.calls.VirtualDiskSnapshots + mock.lockVirtualDiskSnapshots.RUnlock() + return calls +} + // VirtualDisks calls VirtualDisksFunc. func (mock *VirtClientMock) VirtualDisks(namespace string) corev1alpha2.VirtualDiskInterface { if mock.VirtualDisksFunc == nil { diff --git a/images/virtualization-artifact/pkg/controller/service/persistent_volume_claim_service.go b/images/virtualization-artifact/pkg/controller/service/persistent_volume_claim_service.go new file mode 100644 index 0000000000..8fabb5f61a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/persistent_volume_claim_service.go @@ -0,0 +1,472 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "fmt" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + k8serrors "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/utils/ptr" + cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + commonpvc "github.com/deckhouse/virtualization-controller/pkg/common/pvc" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" +) + +const ( + cloneStrategySnapshot = "snapshot" + cloneStrategyCSI = "csi-clone" + cloneStrategyHost = "host-assisted" + + PopulationStrategySnapshot = "snapshot" + PopulationStrategyCSIClone = "csi-clone" + PopulationStrategyHostAssigned = "host-assigned" + PopulationStrategyDVCR = "dvcr" + + // virtualizationAPIGroup is used as the target PVC dataSourceRef API group to + // defer dynamic provisioning of host-assisted import targets (see createTarget). + virtualizationAPIGroup = "virtualization.deckhouse.io" +) + +// PersistentVolumeClaimService is the single entry point for filling a target +// PersistentVolumeClaim with data. Callers describe the desired target PVC +// (Name, Namespace, OwnerReferences, Finalizers, base Spec) together with a +// PVCImportSource (DVCR registry image, another PVC, or nothing for a blank +// target) and let the service decide *how* to populate the PVC: a smart clone +// via VolumeSnapshot, a CSI clone via dataSource, a host-assisted copy via +// the pvc-importer pod, or any other strategy that may be added in the +// future. The service also creates and cleans up every helper resource the +// chosen strategy needs (scratch PVC, pvc-importer pod, secret/configmap +// copies of DVCR auth/CA, VolumeSnapshot, etc.). +// +// PersistentVolumeClaimService is intentionally agnostic of VirtualDisk and +// VirtualImage: the caller passes the owning object as a client.Object so the +// service can attach it as an OwnerReference where appropriate. +type PersistentVolumeClaimService struct { + client client.Client + importer *PVCImporterService + protection *ProtectionService +} + +type VolumeAndAccessModesGetter interface { + GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) +} + +// NewPersistentVolumeClaimService constructs a PersistentVolumeClaimService +// configured with the pvc-importer pod settings and the DVCR settings used to +// derive auth/CA supplements. +func NewPersistentVolumeClaimService( + c client.Client, + dvcrSettings *dvcr.Settings, + protection *ProtectionService, + cfg DiskImporterConfig, +) *PersistentVolumeClaimService { + return &PersistentVolumeClaimService{ + client: c, + importer: NewPVCImporterService(c, dvcrSettings, cfg.Image, cfg.ResourceRequirements, cfg.PullPolicy, cfg.Verbose), + protection: protection, + } +} + +// Finalizers returns the finalizer slice that should be applied to target +// PVCs at creation time so the controller can perform explicit cleanup before +// garbage collection. Callers building target PVC descriptors should populate +// ObjectMeta.Finalizers with this slice. +func (s *PersistentVolumeClaimService) Finalizers() []string { + if s.protection == nil { + return nil + } + finalizer := s.protection.GetFinalizer() + if finalizer == "" { + return nil + } + return []string{finalizer} +} + +func (s *PersistentVolumeClaimService) CreateBlankTarget(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, modeGetter VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + target, err := s.newTargetPVC(ctx, key, storageClassName, size, owner, modeGetter) + if err != nil { + return corev1.PersistentVolumeClaim{}, err + } + if nodePlacement != nil { + if err := provisioner.KeepNodePlacementTolerations(nodePlacement, &target); err != nil { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("keep node placement: %w", err) + } + } + if err := s.client.Create(ctx, &target); err != nil && !k8serrors.IsAlreadyExists(err) { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("create blank target pvc: %w", err) + } + return target, nil +} + +func (s *PersistentVolumeClaimService) CreateTargetFromDVCR(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *PVCImportSourceRegistry, modeGetter VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + target, err := s.newTargetPVC(ctx, key, storageClassName, size, owner, modeGetter) + if err != nil { + return corev1.PersistentVolumeClaim{}, err + } + setPVCPopulationAnnotation(target.Annotations, PopulationStrategyDVCR) + if source != nil { + target.Annotations[annotations.AnnPVCPopulationSourceDVCR] = source.URL + } + target.Spec.DataSourceRef = &corev1.TypedObjectReference{APIGroup: ptr.To(virtualizationAPIGroup), Kind: owner.GetObjectKind().GroupVersionKind().Kind, Name: owner.GetName()} + if nodePlacement != nil { + if err := provisioner.KeepNodePlacementTolerations(nodePlacement, &target); err != nil { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("keep node placement: %w", err) + } + } + if err := s.client.Create(ctx, &target); err != nil && !k8serrors.IsAlreadyExists(err) { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("create dvcr target pvc: %w", err) + } + return target, nil +} + +func (s *PersistentVolumeClaimService) CreateTargetFromVS(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + target, err := s.newTargetPVC(ctx, key, storageClassName, size, owner, modeGetter) + if err != nil { + return corev1.PersistentVolumeClaim{}, err + } + target.Spec.DataSource = &corev1.TypedLocalObjectReference{APIGroup: ptr.To("snapshot.storage.k8s.io"), Kind: "VolumeSnapshot", Name: source.Name} + target.Spec.DataSourceRef = &corev1.TypedObjectReference{APIGroup: ptr.To("snapshot.storage.k8s.io"), Kind: "VolumeSnapshot", Name: source.Name} + if nodePlacement != nil { + if err := provisioner.KeepNodePlacementTolerations(nodePlacement, &target); err != nil { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("keep node placement: %w", err) + } + } + if err := s.client.Create(ctx, &target); err != nil && !k8serrors.IsAlreadyExists(err) { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("create volume snapshot target pvc: %w", err) + } + return target, nil +} + +func (s *PersistentVolumeClaimService) CreateTargetFromPVC(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *corev1.PersistentVolumeClaim, modeGetter VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + target, err := s.newTargetPVC(ctx, key, storageClassName, size, owner, modeGetter) + if err != nil { + return corev1.PersistentVolumeClaim{}, err + } + if err := s.preparePopulationFromPVC(ctx, &target, source, owner); err != nil { + return corev1.PersistentVolumeClaim{}, err + } + if nodePlacement != nil { + if err := provisioner.KeepNodePlacementTolerations(nodePlacement, &target); err != nil { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("keep node placement: %w", err) + } + } + if err := s.client.Create(ctx, &target); err != nil && !k8serrors.IsAlreadyExists(err) { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("create pvc target pvc: %w", err) + } + return target, nil +} + +func (s *PersistentVolumeClaimService) newTargetPVC(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, modeGetter VolumeAndAccessModesGetter) (corev1.PersistentVolumeClaim, error) { + existing, err := object.FetchObject(ctx, key, s.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("fetch target pvc: %w", err) + } + if existing != nil { + return *existing, nil + } + + sc, err := object.FetchObject(ctx, types.NamespacedName{Name: storageClassName}, s.client, &storagev1.StorageClass{}) + if err != nil { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("fetch storage class: %w", err) + } + if sc == nil { + return corev1.PersistentVolumeClaim{}, fmt.Errorf("storage class %q not found", storageClassName) + } + + volumeMode, accessMode, err := modeGetter.GetVolumeAndAccessModes(ctx, owner, sc) + if err != nil { + return corev1.PersistentVolumeClaim{}, err + } + + requested := resource.Quantity{} + if size != nil { + requested = size.DeepCopy() + } + target := corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + Finalizers: s.Finalizers(), + OwnerReferences: []metav1.OwnerReference{MakeControllerOwnerReference(owner)}, + Annotations: map[string]string{}, + }, + Spec: *commonpvc.CreateSpec(&sc.Name, requested, accessMode, volumeMode), + } + return target, nil +} + +func (s *PersistentVolumeClaimService) preparePopulationFromPVC(ctx context.Context, target, source *corev1.PersistentVolumeClaim, owner client.Object) error { + targetSC, err := object.FetchObject(ctx, types.NamespacedName{Name: ptr.Deref(target.Spec.StorageClassName, "")}, s.client, &storagev1.StorageClass{}) + if err != nil { + return fmt.Errorf("fetch target storage class: %w", err) + } + if targetSC == nil { + return fmt.Errorf("target storage class %q not found", ptr.Deref(target.Spec.StorageClassName, "")) + } + targetVolumeMode := corev1.PersistentVolumeFilesystem + if target.Spec.VolumeMode != nil { + targetVolumeMode = *target.Spec.VolumeMode + } + + strategy := s.choosePVCCloneStrategy(ctx, source, targetSC, targetVolumeMode) + target.Spec.Resources.Requests[corev1.ResourceStorage] = pvcCloneTargetSize(target.Spec.Resources.Requests[corev1.ResourceStorage], source) + target.Annotations[annotations.AnnPVCPopulationSourcePVC] = source.Name + + switch strategy { + case PopulationStrategySnapshot: + snapshotName := target.Name + "-clone-snapshot" + setPVCPopulationAnnotation(target.Annotations, PopulationStrategySnapshot) + target.Spec.DataSource = &corev1.TypedLocalObjectReference{APIGroup: ptr.To("snapshot.storage.k8s.io"), Kind: "VolumeSnapshot", Name: snapshotName} + target.Spec.DataSourceRef = &corev1.TypedObjectReference{APIGroup: ptr.To("snapshot.storage.k8s.io"), Kind: "VolumeSnapshot", Name: snapshotName} + return s.ensureCloneSnapshot(ctx, source, target, source) + case PopulationStrategyCSIClone: + setPVCPopulationAnnotation(target.Annotations, PopulationStrategyCSIClone) + target.Spec.DataSource = &corev1.TypedLocalObjectReference{Kind: "PersistentVolumeClaim", Name: source.Name} + target.Spec.DataSourceRef = &corev1.TypedObjectReference{Kind: "PersistentVolumeClaim", Name: source.Name} + default: + setPVCPopulationAnnotation(target.Annotations, PopulationStrategyHostAssigned) + target.Spec.DataSource = nil + target.Spec.DataSourceRef = &corev1.TypedObjectReference{APIGroup: ptr.To(virtualizationAPIGroup), Kind: owner.GetObjectKind().GroupVersionKind().Kind, Name: owner.GetName()} + } + return nil +} + +func setPVCPopulationAnnotation(annotationsMap map[string]string, strategy string) { + annotationsMap[annotations.AnnPVCPopulationStrategy] = strategy +} + +func (s *PersistentVolumeClaimService) WaitForImport(ctx context.Context, target *corev1.PersistentVolumeClaim, source *PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) (corev1.PodPhase, error) { + if source != nil && source.PVC != nil && IsSmartClonePVC(target) { + if target.Status.Phase == corev1.ClaimBound { + return corev1.PodSucceeded, s.cleanupCloneSnapshot(ctx, target) + } + return corev1.PodPending, nil + } + + return s.importer.Wait(ctx, target, sup) +} + +func (s *PersistentVolumeClaimService) Import(ctx context.Context, target *corev1.PersistentVolumeClaim, source *PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) error { + if source != nil && source.PVC != nil && IsSmartClonePVC(target) { + return nil + } + + return s.importer.Import(ctx, target, source, owner, sup, nodePlacement) +} + +// Cleanup removes every helper resource the import has used (pvc-importer +// pod, scratch PVC, clone VolumeSnapshot). It is idempotent and safe to call +// multiple times. +func (s *PersistentVolumeClaimService) Cleanup(ctx context.Context, sup supplements.Generator, target *corev1.PersistentVolumeClaim) (bool, error) { + deleted, err := s.importer.CleanUp(ctx, sup, target) + if err != nil { + return false, err + } + if err := s.cleanupCloneSnapshot(ctx, target); err != nil { + return false, err + } + return deleted, nil +} + +func (s *PersistentVolumeClaimService) choosePVCCloneStrategy(ctx context.Context, sourceClaim *corev1.PersistentVolumeClaim, targetSC *storagev1.StorageClass, targetVolumeMode corev1.PersistentVolumeMode) string { + sourceSC, err := s.fetchSourceStorageClass(ctx, sourceClaim) + if err != nil || sourceSC == nil { + return cloneStrategyHost + } + + preferred := cloneStrategySnapshot + if sp, err := object.FetchObject(ctx, types.NamespacedName{Name: targetSC.Name}, s.client, &cdiv1.StorageProfile{}); err == nil && sp != nil && sp.Status.CloneStrategy != nil { + switch *sp.Status.CloneStrategy { + case cdiv1.CloneStrategyCsiClone: + preferred = cloneStrategyCSI + case cdiv1.CloneStrategyHostAssisted: + preferred = cloneStrategyHost + case cdiv1.CloneStrategySnapshot: + preferred = cloneStrategySnapshot + } + } + + if preferred == cloneStrategySnapshot && s.canSnapshotClone(ctx, sourceClaim, sourceSC, targetSC, targetVolumeMode) { + return cloneStrategySnapshot + } + if preferred != cloneStrategyHost && canCSIClone(sourceClaim, sourceSC, targetSC, targetVolumeMode) { + return cloneStrategyCSI + } + if preferred == cloneStrategyCSI && s.canSnapshotClone(ctx, sourceClaim, sourceSC, targetSC, targetVolumeMode) { + return cloneStrategySnapshot + } + return cloneStrategyHost +} + +func (s *PersistentVolumeClaimService) canSnapshotClone(ctx context.Context, sourceClaim *corev1.PersistentVolumeClaim, sourceSC, targetSC *storagev1.StorageClass, targetVolumeMode corev1.PersistentVolumeMode) bool { + return sourceSC.Provisioner == targetSC.Provisioner && + volumeModesEqual(sourceClaim, targetVolumeMode) && + s.snapshotClassForProvisioner(ctx, sourceSC.Provisioner) != "" +} + +func canCSIClone(sourceClaim *corev1.PersistentVolumeClaim, sourceSC, targetSC *storagev1.StorageClass, targetVolumeMode corev1.PersistentVolumeMode) bool { + return sourceClaim.Namespace != "" && + sourceSC.Provisioner == targetSC.Provisioner && + volumeModesEqual(sourceClaim, targetVolumeMode) +} + +func (s *PersistentVolumeClaimService) fetchSourceStorageClass(ctx context.Context, claim *corev1.PersistentVolumeClaim) (*storagev1.StorageClass, error) { + if claim.Spec.StorageClassName == nil || *claim.Spec.StorageClassName == "" { + return nil, fmt.Errorf("source pvc %s/%s has no storageClassName", claim.Namespace, claim.Name) + } + sc, err := object.FetchObject(ctx, types.NamespacedName{Name: *claim.Spec.StorageClassName}, s.client, &storagev1.StorageClass{}) + if err != nil { + return nil, fmt.Errorf("fetch source storage class: %w", err) + } + if sc == nil { + return nil, fmt.Errorf("source storage class %q not found", *claim.Spec.StorageClassName) + } + return sc, nil +} + +func (s *PersistentVolumeClaimService) snapshotClassForProvisioner(ctx context.Context, provisionerName string) string { + var list vsv1.VolumeSnapshotClassList + if err := s.client.List(ctx, &list); err != nil { + return "" + } + for _, item := range list.Items { + if item.Driver == provisionerName { + return item.Name + } + } + return "" +} + +func (s *PersistentVolumeClaimService) ensureCloneSnapshot(ctx context.Context, sourceClaim, target *corev1.PersistentVolumeClaim, owner client.Object) error { + snapshotName := cloneSnapshotName(target) + if snapshotName == "" { + return fmt.Errorf("clone snapshot name is empty") + } + existing, err := object.FetchObject(ctx, types.NamespacedName{Name: snapshotName, Namespace: target.Namespace}, s.client, &vsv1.VolumeSnapshot{}) + if err != nil { + return fmt.Errorf("fetch clone snapshot: %w", err) + } + if existing != nil { + return nil + } + + ownerRef := ownerReferenceForObject(owner) + ownerRef.Controller = ptr.To(false) + + vs := &vsv1.VolumeSnapshot{ + TypeMeta: metav1.TypeMeta{Kind: "VolumeSnapshot", APIVersion: "snapshot.storage.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: snapshotName, + Namespace: target.Namespace, + OwnerReferences: []metav1.OwnerReference{ownerRef}, + }, + Spec: vsv1.VolumeSnapshotSpec{ + Source: vsv1.VolumeSnapshotSource{ + PersistentVolumeClaimName: ptr.To(sourceClaim.Name), + }, + }, + } + if err := s.client.Create(ctx, vs); err != nil && !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("create clone snapshot: %w", err) + } + return nil +} + +func (s *PersistentVolumeClaimService) cleanupCloneSnapshot(ctx context.Context, target *corev1.PersistentVolumeClaim) error { + if target.Annotations[annotations.AnnPVCPopulationStrategy] != PopulationStrategySnapshot { + return nil + } + snapshotName := snapshotNameFromPVC(target) + if snapshotName == "" { + return nil + } + err := s.client.Delete(ctx, &vsv1.VolumeSnapshot{ObjectMeta: metav1.ObjectMeta{Name: snapshotName, Namespace: target.Namespace}}) + if err != nil && !k8serrors.IsNotFound(err) { + return err + } + return nil +} + +func pvcCloneTargetSize(requested resource.Quantity, sourceClaim *corev1.PersistentVolumeClaim) resource.Quantity { + size := requested.DeepCopy() + for _, candidate := range []resource.Quantity{ + sourceClaim.Spec.Resources.Requests[corev1.ResourceStorage], + sourceClaim.Status.Capacity[corev1.ResourceStorage], + } { + if !candidate.IsZero() && size.Cmp(candidate) < 0 { + size = candidate.DeepCopy() + } + } + return size +} + +func volumeModesEqual(sourceClaim *corev1.PersistentVolumeClaim, targetVolumeMode corev1.PersistentVolumeMode) bool { + sourceMode := corev1.PersistentVolumeFilesystem + if sourceClaim.Spec.VolumeMode != nil { + sourceMode = *sourceClaim.Spec.VolumeMode + } + return sourceMode == targetVolumeMode +} + +func isSmartCloneStrategy(strategy string) bool { + return strategy == cloneStrategySnapshot || strategy == cloneStrategyCSI +} + +// IsSmartClonePVC reports whether the target PVC is provisioned via a smart +// clone (CSI clone or VolumeSnapshot restore). Such targets are dynamically +// provisioned from a dataSource and, unlike host-assisted imports, have no +// importer pod that would trigger binding of a WaitForFirstConsumer volume. +func IsSmartClonePVC(pvc *corev1.PersistentVolumeClaim) bool { + if pvc == nil { + return false + } + return isSmartCloneStrategy(pvc.Annotations[annotations.AnnPVCPopulationStrategy]) +} + +func cloneSnapshotName(target *corev1.PersistentVolumeClaim) string { + if target == nil || target.Name == "" { + return "" + } + return target.Name + "-clone-snapshot" +} + +func snapshotNameFromPVC(target *corev1.PersistentVolumeClaim) string { + if target == nil { + return "" + } + if target.Spec.DataSourceRef != nil && target.Spec.DataSourceRef.Kind == "VolumeSnapshot" { + return target.Spec.DataSourceRef.Name + } + if target.Spec.DataSource != nil && target.Spec.DataSource.Kind == "VolumeSnapshot" { + return target.Spec.DataSource.Name + } + return cloneSnapshotName(target) +} diff --git a/images/virtualization-artifact/pkg/controller/service/persistent_volume_claim_service_test.go b/images/virtualization-artifact/pkg/controller/service/persistent_volume_claim_service_test.go new file mode 100644 index 0000000000..6c4a43d300 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/persistent_volume_claim_service_test.go @@ -0,0 +1,522 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "testing" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + storagev1 "k8s.io/api/storage/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + commonpvc "github.com/deckhouse/virtualization-controller/pkg/common/pvc" + "github.com/deckhouse/virtualization-controller/pkg/controller/storageprofile" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func newTestPVCService(c client.Client) *PersistentVolumeClaimService { + return NewPersistentVolumeClaimService(c, nil, nil, DiskImporterConfig{ + Image: "disk-importer:latest", + PullPolicy: string(corev1.PullIfNotPresent), + Verbose: "3", + }) +} + +type testVolumeModeGetter struct{} + +func (testVolumeModeGetter) GetVolumeAndAccessModes(_ context.Context, _ client.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil +} + +func newTestVDSupplements(vd *v1alpha2.VirtualDisk) supplements.Generator { + return &testVDSupplements{ + Generator: supplements.NewGenerator(annotations.VDShortName, vd.Name, vd.Namespace, vd.UID), + claimName: vd.Status.Target.PersistentVolumeClaim, + } +} + +type testVDSupplements struct { + supplements.Generator + claimName string +} + +func (t *testVDSupplements) PersistentVolumeClaim() types.NamespacedName { + return types.NamespacedName{Name: t.claimName, Namespace: t.Namespace()} +} + +func (t *testVDSupplements) CommonResourceName() types.NamespacedName { + return t.PersistentVolumeClaim() +} + +func newTestTargetPVC(vd *v1alpha2.VirtualDisk, sc *storagev1.StorageClass, size resource.Quantity) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: vd.Status.Target.PersistentVolumeClaim, + Namespace: vd.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.VirtualDiskKind, + Name: vd.Name, + UID: vd.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }}, + }, + Spec: *commonpvc.CreateSpec(&sc.Name, size, corev1.ReadWriteOnce, corev1.PersistentVolumeFilesystem), + } +} + +func TestPVCServiceCreateTargetCreatesPVCWithRegistrySource(t *testing.T) { + ctx := context.Background() + sc := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: "fast"}} + c := fake.NewClientBuilder().WithScheme(diskImportTestScheme(t)).WithObjects(sc).Build() + svc := newTestPVCService(c) + + url := "docker://registry.example/image:tag" + vd := diskImportTestVD() + key := types.NamespacedName{Name: vd.Status.Target.PersistentVolumeClaim, Namespace: vd.Namespace} + + if _, err := svc.CreateTargetFromDVCR(ctx, key, sc.Name, ptr.To(resource.MustParse("1Gi")), vd, &PVCImportSourceRegistry{URL: url}, testVolumeModeGetter{}, nil); err != nil { + t.Fatalf("CreateTargetFromDVCR failed: %v", err) + } + + pvc := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, key, pvc); err != nil { + t.Fatalf("target pvc not found: %v", err) + } + if len(pvc.OwnerReferences) != 1 || pvc.OwnerReferences[0].Kind != v1alpha2.VirtualDiskKind { + t.Fatalf("target pvc owner reference not set: %#v", pvc.OwnerReferences) + } + if got := pvc.Annotations[annotations.AnnPVCPopulationStrategy]; got != PopulationStrategyDVCR { + t.Fatalf("unexpected population strategy: %q", got) + } + if pvc.Spec.DataSourceRef == nil || ptr.Deref(pvc.Spec.DataSourceRef.APIGroup, "") != virtualizationAPIGroup || pvc.Spec.DataSourceRef.Kind != v1alpha2.VirtualDiskKind || pvc.Spec.DataSourceRef.Name != vd.Name { + t.Fatalf("target pvc does not reference VirtualDisk populator source: %#v", pvc.Spec.DataSourceRef) + } + if got := pvc.Annotations[annotations.AnnPVCPopulationSourceDVCR]; got != url { + t.Fatalf("unexpected dvcr source: %q", got) + } +} + +func TestPVCServiceWaitForImportIsResumable(t *testing.T) { + ctx := context.Background() + vd := diskImportTestVD() + target := diskImportTargetPVC(vd) + importerPodName := diskImportImporterPodName(vd) + c := fake.NewClientBuilder().WithScheme(diskImportTestScheme(t)).WithObjects(target).Build() + svc := newTestPVCService(c) + sup := newTestVDSupplements(vd) + + if err := svc.Import(ctx, target, NewPVCRegistryImportSource("", "", ""), vd, sup, nil); err != nil { + t.Fatalf("Import failed: %v", err) + } + + phase, err := svc.WaitForImport(ctx, target, NewPVCRegistryImportSource("", "", ""), vd, sup, nil) + if err != nil { + t.Fatalf("WaitForImport failed: %v", err) + } + if phase != corev1.PodPending { + t.Fatalf("unexpected phase after first WaitForImport: %s", phase) + } + + pod := &corev1.Pod{} + if err := c.Get(ctx, types.NamespacedName{Name: importerPodName, Namespace: target.Namespace}, pod); err != nil { + t.Fatalf("get pod: %v", err) + } + + // The importer fills the prime PVC, not the target. + primeName := primePVCName(target) + prime := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, types.NamespacedName{Name: primeName, Namespace: target.Namespace}, prime); err != nil { + t.Fatalf("get prime pvc: %v", err) + } + if got := pod.Spec.Volumes; len(got) == 0 { + t.Fatalf("importer pod has no volumes") + } + scratch := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, types.NamespacedName{Name: primeName + "-scratch", Namespace: target.Namespace}, scratch); err != nil { + t.Fatalf("get scratch pvc: %v", err) + } + if got, want := scratch.Spec.Resources.Requests[corev1.ResourceStorage], resource.MustParse("1342177280"); got.Cmp(want) != 0 { + t.Fatalf("unexpected scratch size: got %s, want %s", got.String(), want.String()) + } + + // Simulate the provisioner binding the prime PVC to a PersistentVolume. + prime.Spec.VolumeName = "pv-prime" + if err := c.Update(ctx, prime); err != nil { + t.Fatalf("bind prime pvc: %v", err) + } + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "pv-prime"}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + ClaimRef: &corev1.ObjectReference{ + Kind: "PersistentVolumeClaim", Namespace: target.Namespace, Name: prime.Name, UID: prime.UID, + }, + }, + } + if err := c.Create(ctx, pv); err != nil { + t.Fatalf("create prime pv: %v", err) + } + + pod.Status.Phase = corev1.PodSucceeded + if err := c.Status().Update(ctx, pod); err != nil { + t.Fatalf("update pod status: %v", err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(target), target); err != nil { + t.Fatalf("refresh target: %v", err) + } + + phase, err = svc.WaitForImport(ctx, target, NewPVCRegistryImportSource("", "", ""), vd, sup, nil) + if err != nil { + t.Fatalf("WaitForImport after pod success failed: %v", err) + } + if phase != corev1.PodSucceeded { + t.Fatalf("unexpected final phase: %s", phase) + } + + // The prime's volume must have been rebound to the target. + refreshedTarget := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, client.ObjectKeyFromObject(target), refreshedTarget); err != nil { + t.Fatalf("refresh target: %v", err) + } + if refreshedTarget.Spec.VolumeName != "pv-prime" { + t.Fatalf("target was not rebound to prime's PV: volumeName=%q", refreshedTarget.Spec.VolumeName) + } + + // Helper resources must be cleaned up. + if err := c.Get(ctx, types.NamespacedName{Name: primeName, Namespace: target.Namespace}, &corev1.PersistentVolumeClaim{}); client.IgnoreNotFound(err) == nil && err == nil { + t.Fatalf("prime pvc still exists") + } + if err := c.Get(ctx, types.NamespacedName{Name: primeName + "-scratch", Namespace: target.Namespace}, &corev1.PersistentVolumeClaim{}); client.IgnoreNotFound(err) == nil && err == nil { + t.Fatalf("scratch pvc still exists") + } + if err := c.Get(ctx, types.NamespacedName{Name: importerPodName, Namespace: target.Namespace}, &corev1.Pod{}); client.IgnoreNotFound(err) == nil && err == nil { + t.Fatalf("import pod still exists") + } +} + +func TestPVCServiceCreateTargetPicksVolumeSnapshotStrategyWhenPossible(t *testing.T) { + ctx := context.Background() + vd := diskImportTestVD() + sc := diskImportStorageClass() + sourceClaim := diskImportSourcePVC() + snapshotClass := &vsv1.VolumeSnapshotClass{ + ObjectMeta: metav1.ObjectMeta{Name: "snap-fast"}, + Driver: sc.Provisioner, + } + c := fake.NewClientBuilder().WithScheme(diskImportTestScheme(t)).WithObjects(sc, sourceClaim, snapshotClass).Build() + svc := newTestPVCService(c) + target := newTestTargetPVC(vd, sc, resource.MustParse("1Gi")) + + if _, err := svc.CreateTargetFromPVC(ctx, client.ObjectKeyFromObject(target), sc.Name, ptr.To(resource.MustParse("1Gi")), vd, sourceClaim, testVolumeModeGetter{}, nil); err != nil { + t.Fatalf("CreateTargetFromPVC failed: %v", err) + } + + created := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, types.NamespacedName{Name: target.Name, Namespace: target.Namespace}, created); err != nil { + t.Fatalf("target pvc not found: %v", err) + } + if got := created.Annotations[annotations.AnnPVCPopulationStrategy]; got != PopulationStrategySnapshot { + t.Fatalf("unexpected population strategy: %q", got) + } + if created.Spec.DataSourceRef == nil || created.Spec.DataSourceRef.Kind != "VolumeSnapshot" { + t.Fatalf("target pvc does not reference VolumeSnapshot: %#v", created.Spec.DataSourceRef) + } + if got, want := created.Spec.Resources.Requests[corev1.ResourceStorage], resource.MustParse("2Gi"); got.Cmp(want) != 0 { + t.Fatalf("unexpected target size: got %s, want %s", got.String(), want.String()) + } + + snapshot := &vsv1.VolumeSnapshot{} + if err := c.Get(ctx, types.NamespacedName{Name: created.Spec.DataSourceRef.Name, Namespace: vd.Namespace}, snapshot); err != nil { + t.Fatalf("clone snapshot not found: %v", err) + } + if snapshot.Spec.Source.PersistentVolumeClaimName == nil || *snapshot.Spec.Source.PersistentVolumeClaimName != sourceClaim.Name { + t.Fatalf("unexpected snapshot source: %#v", snapshot.Spec.Source.PersistentVolumeClaimName) + } +} + +func TestPVCServiceWaitForImportSmartCloneMarksSucceededAndCleansSnapshot(t *testing.T) { + ctx := context.Background() + vd := diskImportTestVD() + target := diskImportTargetPVC(vd) + target.Annotations[annotations.AnnPVCPopulationStrategy] = PopulationStrategySnapshot + target.Spec.DataSourceRef = &corev1.TypedObjectReference{APIGroup: ptr.To("snapshot.storage.k8s.io"), Kind: "VolumeSnapshot", Name: target.Name + "-clone-snapshot"} + snapshot := &vsv1.VolumeSnapshot{ObjectMeta: metav1.ObjectMeta{Name: target.Spec.DataSourceRef.Name, Namespace: target.Namespace}} + c := fake.NewClientBuilder().WithScheme(diskImportTestScheme(t)).WithObjects(target, snapshot).Build() + svc := newTestPVCService(c) + sup := newTestVDSupplements(vd) + + phase, err := svc.WaitForImport(ctx, target, NewPVCPVCImportSource("source", "default"), vd, sup, nil) + if err != nil { + t.Fatalf("WaitForImport failed: %v", err) + } + if phase != corev1.PodSucceeded { + t.Fatalf("unexpected phase: %s", phase) + } + if err := c.Get(ctx, types.NamespacedName{Name: snapshot.Name, Namespace: snapshot.Namespace}, &vsv1.VolumeSnapshot{}); client.IgnoreNotFound(err) == nil && err == nil { + t.Fatalf("clone snapshot still exists") + } +} + +func TestPVCServiceWaitForImportHostAssistedUsesQemuImgConvert(t *testing.T) { + ctx := context.Background() + vd := diskImportTestVD() + target := diskImportTargetPVC(vd) + target.Annotations[annotations.AnnPVCPopulationStrategy] = PopulationStrategyHostAssigned + target.Spec.VolumeMode = ptr.To(corev1.PersistentVolumeBlock) + sourceClaim := diskImportSourcePVC() + sourceClaim.Spec.VolumeMode = ptr.To(corev1.PersistentVolumeBlock) + c := fake.NewClientBuilder().WithScheme(diskImportTestScheme(t)).WithObjects(target, sourceClaim).Build() + svc := newTestPVCService(c) + sup := newTestVDSupplements(vd) + + if err := svc.Import(ctx, target, NewPVCPVCImportSource(sourceClaim.Name, sourceClaim.Namespace), vd, sup, nil); err != nil { + t.Fatalf("Import failed: %v", err) + } + + phase, err := svc.WaitForImport(ctx, target, NewPVCPVCImportSource(sourceClaim.Name, sourceClaim.Namespace), vd, sup, nil) + if err != nil { + t.Fatalf("WaitForImport failed: %v", err) + } + if phase != corev1.PodPending { + t.Fatalf("unexpected phase: %s", phase) + } + + sourcePod := &corev1.Pod{} + if err := c.Get(ctx, sup.PVCSourceImporterPod(), sourcePod); err != nil { + t.Fatalf("get source import pod: %v", err) + } + targetPod := &corev1.Pod{} + if err := c.Get(ctx, sup.PVCTargetImporterPod(), targetPod); err != nil { + t.Fatalf("get target import pod: %v", err) + } + if err := c.Get(ctx, types.NamespacedName{Name: primePVCName(target) + "-scratch", Namespace: target.Namespace}, &corev1.PersistentVolumeClaim{}); !k8serrors.IsNotFound(err) { + t.Fatalf("host-assigned import must not create scratch pvc, got error: %v", err) + } + sourceContainer := sourcePod.Spec.Containers[0] + if got := sourceContainer.Command; len(got) != 1 || got[0] != "/usr/sbin/nbdkit" { + t.Fatalf("unexpected source command: %#v", got) + } + container := targetPod.Spec.Containers[0] + if got := container.Command; len(got) != 1 || got[0] != "/usr/bin/pvc-target-importer" { + t.Fatalf("unexpected command: %#v", got) + } + wantNBD := "nbd://" + sup.PVCSourceImporterService().Name + ":10809" + var gotOwnerUID, gotNBD string + for _, env := range container.Env { + switch env.Name { + case common.OwnerUID: + gotOwnerUID = env.Value + case common.ImporterNBDEndpoint: + gotNBD = env.Value + } + } + if gotOwnerUID != string(vd.UID) { + t.Fatalf("unexpected owner UID env: got %q, want %q", gotOwnerUID, vd.UID) + } + if gotNBD != wantNBD { + t.Fatalf("unexpected NBD endpoint env: got %q, want %q", gotNBD, wantNBD) + } + if len(container.Ports) != 1 || container.Ports[0].Name != "metrics" || container.Ports[0].ContainerPort != 8443 { + t.Fatalf("unexpected metrics port: %#v", container.Ports) + } +} + +func TestPVCServiceCreateTargetFallsBackToCSIClone(t *testing.T) { + ctx := context.Background() + vd := diskImportTestVD() + sc := diskImportStorageClass() + sourceClaim := diskImportSourcePVC() + c := fake.NewClientBuilder().WithScheme(diskImportTestScheme(t)).WithObjects(sc, sourceClaim).Build() + svc := newTestPVCService(c) + target := newTestTargetPVC(vd, sc, resource.MustParse("1Gi")) + + if _, err := svc.CreateTargetFromPVC(ctx, client.ObjectKeyFromObject(target), sc.Name, ptr.To(resource.MustParse("1Gi")), vd, sourceClaim, testVolumeModeGetter{}, nil); err != nil { + t.Fatalf("CreateTargetFromPVC failed: %v", err) + } + + created := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, types.NamespacedName{Name: target.Name, Namespace: target.Namespace}, created); err != nil { + t.Fatalf("target pvc not found: %v", err) + } + if got := created.Annotations[annotations.AnnPVCPopulationStrategy]; got != PopulationStrategyCSIClone { + t.Fatalf("unexpected population strategy: %q", got) + } + if created.Spec.DataSourceRef == nil || created.Spec.DataSourceRef.Kind != "PersistentVolumeClaim" || created.Spec.DataSourceRef.Name != sourceClaim.Name { + t.Fatalf("target pvc does not reference source PVC: %#v", created.Spec.DataSourceRef) + } +} + +func TestPVCServiceCreateTargetFromPVCUsesSnapshotForSDSReplicated(t *testing.T) { + ctx := context.Background() + vd := diskImportTestVD() + sc := diskImportStorageClass() + sc.Provisioner = storageprofile.SDSReplicatedCSIProvisioner + sourceClaim := diskImportSourcePVC() + snapshotClass := &vsv1.VolumeSnapshotClass{ + ObjectMeta: metav1.ObjectMeta{Name: "sds-replicated-volume"}, + Driver: sc.Provisioner, + } + snapshot := cdiv1.CloneStrategySnapshot + sp := &cdiv1.StorageProfile{ + ObjectMeta: metav1.ObjectMeta{Name: sc.Name}, + Status: cdiv1.StorageProfileStatus{ + CloneStrategy: &snapshot, + }, + } + c := fake.NewClientBuilder().WithScheme(diskImportTestScheme(t)).WithObjects(sc, sourceClaim, snapshotClass, sp).Build() + svc := newTestPVCService(c) + target := newTestTargetPVC(vd, sc, resource.MustParse("1Gi")) + + if _, err := svc.CreateTargetFromPVC(ctx, client.ObjectKeyFromObject(target), sc.Name, ptr.To(resource.MustParse("1Gi")), vd, sourceClaim, testVolumeModeGetter{}, nil); err != nil { + t.Fatalf("CreateTargetFromPVC failed: %v", err) + } + + created := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, types.NamespacedName{Name: target.Name, Namespace: target.Namespace}, created); err != nil { + t.Fatalf("target pvc not found: %v", err) + } + if got := created.Annotations[annotations.AnnPVCPopulationStrategy]; got != PopulationStrategySnapshot { + t.Fatalf("unexpected population strategy: %q", got) + } + if got := created.Annotations[annotations.AnnPVCPopulationSourcePVC]; got != sourceClaim.Name { + t.Fatalf("unexpected source pvc: %q", got) + } + if created.Spec.DataSourceRef == nil || created.Spec.DataSourceRef.Kind != "VolumeSnapshot" { + t.Fatalf("target pvc does not reference VolumeSnapshot: %#v", created.Spec.DataSourceRef) + } + snapshotObj := &vsv1.VolumeSnapshot{} + if err := c.Get(ctx, types.NamespacedName{Name: created.Spec.DataSourceRef.Name, Namespace: vd.Namespace}, snapshotObj); err != nil { + t.Fatalf("clone snapshot not found: %v", err) + } +} + +func diskImportTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := storagev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := netv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := vsv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := cdiv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := v1alpha2.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + return scheme +} + +func diskImportStorageClass() *storagev1.StorageClass { + return &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fast", + Annotations: map[string]string{ + annotations.AnnVirtualDiskVolumeMode: string(corev1.PersistentVolumeFilesystem), + annotations.AnnVirtualDiskAccessMode: string(corev1.ReadWriteOnce), + }, + }, + Provisioner: "csi.example.com", + } +} + +func diskImportSourcePVC() *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: "default"}, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: ptr.To("fast"), + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}, + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("2Gi"), + }, + }, + } +} + +func diskImportTestVD() *v1alpha2.VirtualDisk { + return &v1alpha2.VirtualDisk{ + TypeMeta: metav1.TypeMeta{APIVersion: v1alpha2.SchemeGroupVersion.String(), Kind: v1alpha2.VirtualDiskKind}, + ObjectMeta: metav1.ObjectMeta{ + Name: "disk", + Namespace: "default", + UID: "22222222-2222-2222-2222-222222222222", + }, + Status: v1alpha2.VirtualDiskStatus{Target: v1alpha2.DiskTarget{PersistentVolumeClaim: "d8v-vd-22222222-2222-2222-2222-222222222222-abcde"}}, + } +} + +func diskImportImporterPodName(vd *v1alpha2.VirtualDisk) string { + return "d8v-vd-pvc-importer-" + string(vd.UID) +} + +func diskImportTargetPVC(vd *v1alpha2.VirtualDisk) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: vd.Status.Target.PersistentVolumeClaim, + Namespace: vd.Namespace, + UID: "33333333-3333-3333-3333-333333333333", + Annotations: map[string]string{}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.VirtualDiskKind, + Name: vd.Name, + UID: vd.UID, + Controller: ptr.To(true), + }}, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: ptr.To("fast"), + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}, + }, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } +} diff --git a/images/virtualization-artifact/pkg/controller/service/pvc_importer_service.go b/images/virtualization-artifact/pkg/controller/service/pvc_importer_service.go new file mode 100644 index 0000000000..d0db4e3623 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/pvc_importer_service.go @@ -0,0 +1,862 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + k8serrors "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" + + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + networkpolicy "github.com/deckhouse/virtualization-controller/pkg/common/network_policy" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/controller/importer" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements/copier" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" +) + +const ( + pvcImporterDataVolName = "pvc-importer-data-vol" + pvcImporterScratchVolName = "pvc-importer-scratch-vol" + pvcImporterSourceVolName = "pvc-importer-source-vol" + pvcImporterDataDir = "/data" + pvcImporterScratchDataDir = "/scratch" + pvcImporterWriteBlockPath = "/dev/pvc-importer-block-volume" + pvcImporterSourceBlockPath = "/dev/source-block-volume" + pvcImporterNBDPort = 10809 + sourceRegistry = "registry" +) + +// PVCImporterService drives the pvc-importer pod that fills a target PVC with +// data fetched from a registry (DVCR) or another PVC. It owns the scratch PVC +// and the pvc-importer pod and is intentionally agnostic of VirtualDisk and +// VirtualImage; callers pass the target PVC, a PVCImportSource, an owner +// client.Object (used for OwnerReferences on the supplemental secret/configmap +// copies) and a supplements.Generator that yields stable names for the helper +// resources. +// +// Callers create the target PVC themselves (with their own owner reference and +// finalizer) and then ask PVCImporterService to perform the rest of the +// import: copy DVCR auth/cert supplements, ensure the scratch PVC, ensure the +// pvc-importer pod, and clean up the helper resources when the import has +// finished. +type PVCImporterService struct { + client client.Client + dvcrSettings *dvcr.Settings + image string + resourceRequirements corev1.ResourceRequirements + pullPolicy string + verbose string +} + +// NewPVCImporterService returns a PVCImporterService configured with the +// pvc-importer pod settings (image, resources, pull policy, verbosity) and +// the DVCR settings used to derive auth/CA supplements. +func NewPVCImporterService( + c client.Client, + dvcrSettings *dvcr.Settings, + image string, + resourceRequirements corev1.ResourceRequirements, + pullPolicy string, + verbose string, +) *PVCImporterService { + return &PVCImporterService{ + client: c, + dvcrSettings: dvcrSettings, + image: image, + resourceRequirements: resourceRequirements, + pullPolicy: pullPolicy, + verbose: verbose, + } +} + +// Import starts the import: it makes sure the helper +// secret/configmap copies exist, the scratch PVC exists, and the pvc-importer +// pod has been created. +// +// The caller is responsible for creating the target PVC up front; Import does +// not validate ownership or finalizers on it. +func (s *PVCImporterService) Import(ctx context.Context, target *corev1.PersistentVolumeClaim, source *PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) error { + if target.Annotations[annotations.AnnPVCPopulationDone] == "true" { + return nil + } + + if source != nil && source.PVC != nil { + return s.importFromPVC(ctx, target, source, owner, sup, nodePlacement) + } + + if err := s.ensureSupplements(ctx, target, owner, sup); err != nil { + return err + } + + // The importer fills a dedicated prime PVC instead of the target. This keeps the + // target PVC untouched until the import has finished and its populated volume is + // rebound to the target (see Wait/Rebind), so the importer pod and the consuming + // VirtualMachine never contend for the same ReadWriteOnce volume. + prime, err := s.ensurePrime(ctx, target, nodePlacement) + if err != nil { + return err + } + + scratch, err := s.ensureScratch(ctx, prime) + if err != nil { + return err + } + + // The pvc-importer pod must be allowed egress (notably to DVCR) even inside + // network-isolated namespaces (e.g. Deckhouse Projects with networkPolicy: Isolated). + if err := s.ensureNetworkPolicy(ctx, target, sup); err != nil { + return err + } + + var sourceClaim *corev1.PersistentVolumeClaim + if source != nil && source.PVC != nil { + sourceClaim, err = object.FetchObject(ctx, types.NamespacedName{Name: source.PVC.Name, Namespace: source.PVC.Namespace}, s.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + return fmt.Errorf("fetch source pvc: %w", err) + } + if sourceClaim == nil { + return fmt.Errorf("source pvc %s/%s not found", source.PVC.Namespace, source.PVC.Name) + } + } + + podKey := sup.PVCImporterPod() + pod, err := object.FetchObject(ctx, podKey, s.client, &corev1.Pod{}) + if err != nil { + return fmt.Errorf("fetch importer pod: %w", err) + } + if pod == nil { + pod = s.makeImporterPod(podKey.Name, target, prime, owner.GetUID(), source, sourceClaim, scratch.Name, nodePlacement) + if err := s.client.Create(ctx, pod); err != nil && !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("create importer pod: %w", err) + } + } + return nil +} + +func (s *PVCImporterService) importFromPVC(ctx context.Context, target *corev1.PersistentVolumeClaim, source *PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) error { + sourceClaim, err := object.FetchObject(ctx, types.NamespacedName{Name: source.PVC.Name, Namespace: source.PVC.Namespace}, s.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + return fmt.Errorf("fetch source pvc: %w", err) + } + if sourceClaim == nil { + return fmt.Errorf("source pvc %s/%s not found", source.PVC.Namespace, source.PVC.Name) + } + + prime, err := s.ensurePrime(ctx, target, nodePlacement) + if err != nil { + return err + } + + if err := s.ensureNetworkPolicy(ctx, target, sup); err != nil { + return err + } + if err := s.ensureSourceImporterService(ctx, target, sup); err != nil { + return err + } + + sourceNBDHost := sup.PVCSourceImporterService().Name + + sourcePodKey := sup.PVCSourceImporterPod() + sourcePod, err := object.FetchObject(ctx, sourcePodKey, s.client, &corev1.Pod{}) + if err != nil { + return fmt.Errorf("fetch source importer pod: %w", err) + } + if sourcePod == nil { + sourcePod = s.makeSourceImporterPod(sourcePodKey.Name, target, sourceClaim) + if err := s.client.Create(ctx, sourcePod); err != nil && !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("create source importer pod: %w", err) + } + } else if sourcePod.Status.Phase == corev1.PodFailed { + return nil + } + + targetPodKey := sup.PVCTargetImporterPod() + targetPod, err := object.FetchObject(ctx, targetPodKey, s.client, &corev1.Pod{}) + if err != nil { + return fmt.Errorf("fetch target importer pod: %w", err) + } + if targetPod == nil { + targetPod = s.makeTargetImporterPod(targetPodKey.Name, target, prime, owner.GetUID(), sourceNBDHost, nodePlacement) + if err := s.client.Create(ctx, targetPod); err != nil && !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("create target importer pod: %w", err) + } + } + return nil +} + +// primePVCName returns the name of the prime PVC that the importer fills for the +// given target PVC. +func primePVCName(target *corev1.PersistentVolumeClaim) string { + return target.Name + "-prime" +} + +// selectedNodeAnnotation is the standard annotation the scheduler sets on a +// WaitForFirstConsumer PVC to pin its provisioning to a node. The prime PVC and +// the importer pod are pinned to the same node as the target so the populated +// volume can later be rebound to the target without a cross-node move. +const selectedNodeAnnotation = "volume.kubernetes.io/selected-node" + +// SelectedNodeAnnotation is the scheduler annotation that pins a WFFC PVC to a node. +const SelectedNodeAnnotation = selectedNodeAnnotation + +// ensurePrime creates the prime PVC the importer writes into. The prime mirrors +// the target's storage spec (storage class, size, access/volume modes) but never +// carries the target's data source: it is filled by the pvc-importer pod and its +// volume is later rebound to the target. The prime is owned by the target so it +// is garbage-collected if the disk is removed before the import finishes, and is +// excluded from namespace quota accounting. +func (s *PVCImporterService) ensurePrime(ctx context.Context, target *corev1.PersistentVolumeClaim, nodePlacement *provisioner.NodePlacement) (*corev1.PersistentVolumeClaim, error) { + name := primePVCName(target) + prime, err := object.FetchObject(ctx, types.NamespacedName{Name: name, Namespace: target.Namespace}, s.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + return nil, fmt.Errorf("fetch prime pvc: %w", err) + } + if prime != nil { + return prime, nil + } + + prime = &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: target.Namespace, + Labels: map[string]string{ + annotations.QuotaExcludeLabel: annotations.QuotaExcludeValue, + }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: target.Name, + UID: target.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }}, + }, + Spec: *target.Spec.DeepCopy(), + } + prime.Spec.VolumeName = "" + prime.Spec.DataSource = nil + prime.Spec.DataSourceRef = nil + + // Pin the prime to the consuming VirtualMachine's node so the populated volume is + // local to that node and can later be rebound to the target and attached to the VM + // without a cross-node move. The VM's node (nodePlacement.Node) is preferred because + // a VM's disk is hotplug-attached and never sets the target PVC's selected-node + // annotation itself; the target's selected-node is used only as a fallback. + selectedNode := target.Annotations[selectedNodeAnnotation] + if nodePlacement != nil && nodePlacement.Node != "" { + selectedNode = nodePlacement.Node + } + if selectedNode != "" { + if prime.Annotations == nil { + prime.Annotations = map[string]string{} + } + prime.Annotations[selectedNodeAnnotation] = selectedNode + } + + if nodePlacement != nil { + if err := provisioner.KeepNodePlacementTolerations(nodePlacement, prime); err != nil { + return nil, fmt.Errorf("keep node placement on prime: %w", err) + } + } + + if err := s.client.Create(ctx, prime); err != nil && !k8serrors.IsAlreadyExists(err) { + return nil, fmt.Errorf("create prime pvc: %w", err) + } + return prime, nil +} + +func (s *PVCImporterService) Wait(ctx context.Context, target *corev1.PersistentVolumeClaim, sup supplements.Generator) (corev1.PodPhase, error) { + if target.Annotations[annotations.AnnPVCPopulationDone] == "true" { + return corev1.PodSucceeded, nil + } + + if target.Annotations[annotations.AnnPVCPopulationStrategy] == PopulationStrategyHostAssigned { + return s.waitHostAssigned(ctx, target, sup) + } + + pod, err := object.FetchObject(ctx, sup.PVCImporterPod(), s.client, &corev1.Pod{}) + if err != nil { + return "", fmt.Errorf("fetch importer pod: %w", err) + } + if pod == nil { + return corev1.PodPending, nil + } + if pod.Status.Phase == "" { + return corev1.PodPending, nil + } + if pod.Status.Phase == corev1.PodSucceeded { + // The prime PVC is populated; rebind its volume to the target. Rebind is + // idempotent and resumable: it returns false until the target is Bound, so + // the import is only reported Succeeded once the target carries the data. + primeKey := types.NamespacedName{Name: primePVCName(target), Namespace: target.Namespace} + done, err := Rebind(ctx, s.client, primeKey, client.ObjectKeyFromObject(target)) + if err != nil { + return "", fmt.Errorf("rebind prime to target: %w", err) + } + if !done { + return corev1.PodRunning, nil + } + _, err = s.CleanUp(ctx, sup, target) + return corev1.PodSucceeded, err + } + return pod.Status.Phase, nil +} + +func (s *PVCImporterService) waitHostAssigned(ctx context.Context, target *corev1.PersistentVolumeClaim, sup supplements.Generator) (corev1.PodPhase, error) { + sourcePod, err := object.FetchObject(ctx, sup.PVCSourceImporterPod(), s.client, &corev1.Pod{}) + if err != nil { + return "", fmt.Errorf("fetch source importer pod: %w", err) + } + if sourcePod != nil && sourcePod.Status.Phase == corev1.PodFailed { + return corev1.PodFailed, nil + } + + targetPod, err := object.FetchObject(ctx, sup.PVCTargetImporterPod(), s.client, &corev1.Pod{}) + if err != nil { + return "", fmt.Errorf("fetch target importer pod: %w", err) + } + if targetPod == nil || targetPod.Status.Phase == "" { + return corev1.PodPending, nil + } + if targetPod.Status.Phase != corev1.PodSucceeded { + return targetPod.Status.Phase, nil + } + + primeKey := types.NamespacedName{Name: primePVCName(target), Namespace: target.Namespace} + done, err := Rebind(ctx, s.client, primeKey, client.ObjectKeyFromObject(target)) + if err != nil { + return "", fmt.Errorf("rebind prime to target: %w", err) + } + if !done { + return corev1.PodRunning, nil + } + _, err = s.CleanUp(ctx, sup, target) + return corev1.PodSucceeded, err +} + +// CleanUp removes the pvc-importer pod and the helper PVCs (prime and its +// scratch) associated with the target PVC. The prime PVC is normally deleted by +// Rebind once its volume has been transferred to the target; deleting it here is +// a safe, idempotent fallback for imports that were abandoned before completion. +// CleanUp is idempotent: missing resources are ignored. +func (s *PVCImporterService) CleanUp(ctx context.Context, sup supplements.Generator, target *corev1.PersistentVolumeClaim) (bool, error) { + var deleted bool + for _, obj := range []client.Object{ + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: sup.PVCSourceImporterService().Name, Namespace: target.Namespace}}, + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: sup.PVCImporterPod().Name, Namespace: target.Namespace}}, + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: sup.PVCSourceImporterPod().Name, Namespace: target.Namespace}}, + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: sup.PVCTargetImporterPod().Name, Namespace: target.Namespace}}, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: primePVCName(target) + "-scratch", Namespace: target.Namespace}}, + &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: primePVCName(target), Namespace: target.Namespace}}, + } { + err := s.client.Delete(ctx, obj) + switch { + case err == nil: + deleted = true + case !k8serrors.IsNotFound(err): + return false, err + } + } + return deleted, nil +} + +// ensureSupplements copies the DVCR auth secret and CA bundle into the +// target's namespace under stable supplemental names, owned by target so +// they get garbage-collected together with it. +func (s *PVCImporterService) ensureSupplements(ctx context.Context, target *corev1.PersistentVolumeClaim, _ client.Object, supGen supplements.Generator) error { + if s.dvcrSettings == nil { + return nil + } + + ownerRef := metav1.OwnerReference{ + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: target.Name, + UID: target.UID, + Controller: ptr.To(false), + BlockOwnerDeletion: ptr.To(true), + } + + if s.dvcrSettings.AuthSecret != "" { + authCopier := copier.AuthSecret{ + Secret: copier.Secret{ + Source: types.NamespacedName{ + Name: s.dvcrSettings.AuthSecret, + Namespace: s.dvcrSettings.AuthSecretNamespace, + }, + Destination: supGen.DVCRAuthSecretForDV(), + OwnerReference: ownerRef, + }, + } + if err := authCopier.CopyCDICompatible(ctx, s.client, s.dvcrSettings.RegistryURL); err != nil { + return fmt.Errorf("copy dvcr auth secret: %w", err) + } + } + + if s.dvcrSettings.CertsSecret != "" { + caBundleCopier := copier.CABundleConfigMap{ + SourceSecret: types.NamespacedName{ + Name: s.dvcrSettings.CertsSecret, + Namespace: s.dvcrSettings.CertsSecretNamespace, + }, + Destination: supGen.DVCRCABundleConfigMapForDV(), + OwnerReference: ownerRef, + } + if err := caBundleCopier.Copy(ctx, s.client); err != nil { + return fmt.Errorf("copy dvcr ca bundle: %w", err) + } + } + + return nil +} + +// ensureScratch makes sure the scratch PVC exists; it is created sized as the +// base PVC (the prime) plus a small overhead and owned by it so it is +// garbage-collected once the import finishes. +func (s *PVCImporterService) ensureScratch(ctx context.Context, base *corev1.PersistentVolumeClaim) (*corev1.PersistentVolumeClaim, error) { + name := base.Name + "-scratch" + scratch, err := object.FetchObject(ctx, types.NamespacedName{Name: name, Namespace: base.Namespace}, s.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + return nil, fmt.Errorf("fetch scratch pvc: %w", err) + } + if scratch != nil { + return scratch, nil + } + + size := scratchPVCSize(base.Spec.Resources.Requests[corev1.ResourceStorage]) + volumeMode := corev1.PersistentVolumeFilesystem + scratch = &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: base.Namespace, + Labels: map[string]string{ + annotations.QuotaExcludeLabel: annotations.QuotaExcludeValue, + }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: base.Name, + UID: base.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }}, + }, + Spec: *base.Spec.DeepCopy(), + } + scratch.Spec.VolumeName = "" + scratch.Spec.VolumeMode = &volumeMode + scratch.Spec.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} + scratch.Spec.Resources.Requests[corev1.ResourceStorage] = size + if err := s.client.Create(ctx, scratch); err != nil && !k8serrors.IsAlreadyExists(err) { + return nil, fmt.Errorf("create scratch pvc: %w", err) + } + return scratch, nil +} + +// ensureNetworkPolicy creates a NetworkPolicy that allows the pvc-importer pod egress +// (notably to DVCR) and ingress from sibling importer pods and from the +// virtualization-controller namespace (progress metrics scrape). It is required in +// network-isolated namespaces where a default-deny policy would otherwise block the +// import. The policy selects the importer pods by their app label, allows all egress, +// and is owned by the target PVC so it is garbage-collected with the disk. It is +// idempotent and coexists with the NetworkPolicy that ImporterService creates for +// DVCR-fed imports. +func (s *PVCImporterService) ensureNetworkPolicy(ctx context.Context, target *corev1.PersistentVolumeClaim, sup supplements.Generator) error { + controllerNamespace := "" + if s.dvcrSettings != nil { + controllerNamespace = s.dvcrSettings.ControllerNamespace + } + npName := sup.NetworkPolicy() + np := &netv1.NetworkPolicy{ + TypeMeta: metav1.TypeMeta{Kind: "NetworkPolicy", APIVersion: "networking.k8s.io/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: npName.Name, + Namespace: npName.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: target.Name, + UID: target.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }}, + }, + Spec: netv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: annotations.AppLabel, + Operator: metav1.LabelSelectorOpIn, + Values: []string{annotations.CDILabelValue, annotations.DVCRLabelValue}, + }}, + }, + Ingress: []netv1.NetworkPolicyIngressRule{{ + From: networkpolicy.PVCImporterIngressPeers(controllerNamespace), + }}, + Egress: []netv1.NetworkPolicyEgressRule{{}}, + PolicyTypes: []netv1.PolicyType{netv1.PolicyTypeIngress, netv1.PolicyTypeEgress}, + }, + } + + return client.IgnoreAlreadyExists(s.client.Create(ctx, np)) +} + +func (s *PVCImporterService) ensureSourceImporterService(ctx context.Context, target *corev1.PersistentVolumeClaim, sup supplements.Generator) error { + svcKey := sup.PVCSourceImporterService() + svc := &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: svcKey.Name, + Namespace: svcKey.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: target.Name, + UID: target.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + annotations.AppLabel: annotations.CDILabelValue, + annotations.PVCImportRoleLabel: annotations.PVCImportRoleSource, + }, + Ports: []corev1.ServicePort{{ + Name: "nbd", + Port: pvcImporterNBDPort, + TargetPort: intstr.FromInt32(pvcImporterNBDPort), + Protocol: corev1.ProtocolTCP, + }}, + }, + } + return client.IgnoreAlreadyExists(s.client.Create(ctx, svc)) +} + +func scratchPVCSize(targetSize resource.Quantity) resource.Quantity { + size := targetSize.DeepCopy() + minOverhead := resource.MustParse("256Mi") + overhead := *resource.NewQuantity(targetSize.Value()/10, targetSize.Format) + if overhead.Cmp(minOverhead) < 0 { + overhead = minOverhead + } + size.Add(overhead) + return size +} + +// makeImporterPod builds the pvc-importer pod descriptor. It fills dataPVC (the +// prime PVC) with the imported data, while ownership/UID are taken from target +// (the VirtualDisk's PVC) so the pod is garbage-collected with the disk and +// labelled to be excluded from namespace quota accounting. +func (s *PVCImporterService) makeImporterPod(podName string, target, dataPVC *corev1.PersistentVolumeClaim, ownerUID types.UID, source *PVCImportSource, sourceClaim *corev1.PersistentVolumeClaim, scratchName string, nodePlacement *provisioner.NodePlacement) *corev1.Pod { + registryEndpoint := "" + if source != nil && source.Registry != nil { + registryEndpoint = source.Registry.URL + } + imageSize := dataPVC.Spec.Resources.Requests[corev1.ResourceStorage] + + container := corev1.Container{ + Name: "d8v-pvc-importer", + Image: s.image, + ImagePullPolicy: corev1.PullPolicy(s.pullPolicy), + Command: []string{"/usr/bin/pvc-importer"}, + Args: []string{"-v=" + s.verbose}, + Env: []corev1.EnvVar{ + {Name: common.ImporterSource, Value: sourceRegistry}, + {Name: common.ImporterEndpoint, Value: registryEndpoint}, + {Name: common.ImporterContentType, Value: "kubevirt"}, + {Name: common.ImporterImageSize, Value: imageSize.String()}, + // The progress metric (kubevirt_cdi_import_progress_total) is labelled with + // this ownerUID; it must be the VirtualDisk's UID so the controller's progress + // scraper (which queries by vd.GetUID()) can match it. Using the target PVC UID + // here would make progress appear stuck (jumping 0->100 / 50->100). + {Name: common.OwnerUID, Value: string(ownerUID)}, + {Name: common.FilesystemOverheadVar, Value: "0"}, + {Name: common.InsecureTLSVar, Value: "false"}, + }, + VolumeMounts: []corev1.VolumeMount{{Name: pvcImporterScratchVolName, MountPath: pvcImporterScratchDataDir}, {Name: "tmp", MountPath: "/tmp"}}, + Ports: []corev1.ContainerPort{{Name: "metrics", ContainerPort: 8443, Protocol: corev1.ProtocolTCP}}, + } + if s.resourceRequirements.Requests != nil || s.resourceRequirements.Limits != nil { + container.Resources = s.resourceRequirements + } + if source != nil && source.Registry != nil && source.Registry.Secret != "" { + secretName := source.Registry.Secret + container.Env = append(container.Env, corev1.EnvVar{ + Name: common.ImporterAccessKeyID, + ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: importer.KeyAccess, + }}, + }, corev1.EnvVar{ + Name: common.ImporterSecretKey, + ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: importer.KeySecret, + }}, + }) + } + if source != nil && source.Registry != nil && source.Registry.CertConfigMap != "" { + container.Env = append(container.Env, corev1.EnvVar{Name: common.ImporterCertDirVar, Value: common.ImporterCertDir}) + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{Name: "cert-vol", MountPath: common.ImporterCertDir}) + } + if dataPVC.Spec.VolumeMode != nil && *dataPVC.Spec.VolumeMode == corev1.PersistentVolumeBlock { + container.VolumeDevices = []corev1.VolumeDevice{{Name: pvcImporterDataVolName, DevicePath: pvcImporterWriteBlockPath}} + } else { + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{Name: pvcImporterDataVolName, MountPath: pvcImporterDataDir}) + } + + volumes := []corev1.Volume{ + {Name: "tmp", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + {Name: pvcImporterDataVolName, VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: dataPVC.Name}}}, + {Name: pvcImporterScratchVolName, VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: scratchName}}}, + } + if source != nil && source.Registry != nil && source.Registry.CertConfigMap != "" { + volumes = append(volumes, corev1.Volume{ + Name: "cert-vol", + VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: source.Registry.CertConfigMap}, + }}, + }) + } + if source != nil && source.PVC != nil && sourceClaim != nil { + sourcePath := "/source/disk.img" + if sourceClaim.Spec.VolumeMode != nil && *sourceClaim.Spec.VolumeMode == corev1.PersistentVolumeBlock { + sourcePath = pvcImporterSourceBlockPath + container.VolumeDevices = append(container.VolumeDevices, corev1.VolumeDevice{Name: "source-vol", DevicePath: pvcImporterSourceBlockPath}) + } else { + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{Name: "source-vol", MountPath: "/source", ReadOnly: true}) + } + + targetPath := pvcImporterDataDir + "/disk.img" + if dataPVC.Spec.VolumeMode != nil && *dataPVC.Spec.VolumeMode == corev1.PersistentVolumeBlock { + targetPath = pvcImporterWriteBlockPath + } + + container.Command = []string{"/usr/bin/qemu-img"} + container.Args = []string{"convert", "-p", "-O", "raw", sourcePath, targetPath} + container.Env = nil + container.Ports = nil + volumes = append(volumes, corev1.Volume{ + Name: "source-vol", + VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: source.PVC.Name, + ReadOnly: true, + }}, + }) + } + + pod := &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: target.Namespace, + Labels: map[string]string{ + annotations.QuotaExcludeLabel: annotations.QuotaExcludeValue, + // Matches the importer NetworkPolicy selector so the pod is allowed + // egress (to DVCR) in network-isolated namespaces. + annotations.AppLabel: annotations.CDILabelValue, + }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: target.Name, + UID: target.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{container}, + RestartPolicy: corev1.RestartPolicyOnFailure, + Volumes: volumes, + }, + } + podutil.SetRestrictedSecurityContext(&pod.Spec) + if nodePlacement != nil { + pod.Spec.Tolerations = nodePlacement.Tolerations + _ = provisioner.KeepNodePlacementTolerations(nodePlacement, pod) + } + + // Pin the importer to the node the prime volume is provisioned on so the pod can + // mount it (ReadWriteOnce) and so the populated volume stays local to the + // consuming VirtualMachine's node. + if selectedNode := dataPVC.Annotations[selectedNodeAnnotation]; selectedNode != "" { + pod.Spec.Affinity = &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: corev1.LabelHostname, + Operator: corev1.NodeSelectorOpIn, + Values: []string{selectedNode}, + }}, + }}, + }, + }, + } + } + return pod +} + +func (s *PVCImporterService) makeSourceImporterPod(podName string, target, sourceClaim *corev1.PersistentVolumeClaim) *corev1.Pod { + sourcePath := "/source/disk.img" + var volumeDevices []corev1.VolumeDevice + volumeMounts := []corev1.VolumeMount{{Name: "tmp", MountPath: "/tmp"}} + if sourceClaim.Spec.VolumeMode != nil && *sourceClaim.Spec.VolumeMode == corev1.PersistentVolumeBlock { + sourcePath = pvcImporterSourceBlockPath + volumeDevices = append(volumeDevices, corev1.VolumeDevice{Name: pvcImporterSourceVolName, DevicePath: pvcImporterSourceBlockPath}) + } else { + volumeMounts = append(volumeMounts, corev1.VolumeMount{Name: pvcImporterSourceVolName, MountPath: "/source", ReadOnly: true}) + } + + pod := &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: target.Namespace, + Labels: map[string]string{ + annotations.QuotaExcludeLabel: annotations.QuotaExcludeValue, + annotations.AppLabel: annotations.CDILabelValue, + annotations.PVCImportRoleLabel: annotations.PVCImportRoleSource, + }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: target.Name, + UID: target.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "d8v-pvc-source-importer", + Image: s.image, + ImagePullPolicy: corev1.PullPolicy(s.pullPolicy), + Command: []string{"/usr/sbin/nbdkit"}, + Args: []string{"-f", "-r", "-p", fmt.Sprintf("%d", pvcImporterNBDPort), "file", sourcePath}, + Ports: []corev1.ContainerPort{{Name: "nbd", ContainerPort: pvcImporterNBDPort, Protocol: corev1.ProtocolTCP}}, + VolumeMounts: volumeMounts, + VolumeDevices: volumeDevices, + }}, + RestartPolicy: corev1.RestartPolicyOnFailure, + Volumes: []corev1.Volume{ + {Name: "tmp", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + {Name: pvcImporterSourceVolName, VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: sourceClaim.Name, + ReadOnly: true, + }}}, + }, + }, + } + if s.resourceRequirements.Requests != nil || s.resourceRequirements.Limits != nil { + pod.Spec.Containers[0].Resources = s.resourceRequirements + } + podutil.SetRestrictedSecurityContext(&pod.Spec) + return pod +} + +func (s *PVCImporterService) makeTargetImporterPod(podName string, target, dataPVC *corev1.PersistentVolumeClaim, ownerUID types.UID, sourceNBDHost string, nodePlacement *provisioner.NodePlacement) *corev1.Pod { + volumeMounts := []corev1.VolumeMount{{Name: "tmp", MountPath: "/tmp"}} + var volumeDevices []corev1.VolumeDevice + if dataPVC.Spec.VolumeMode != nil && *dataPVC.Spec.VolumeMode == corev1.PersistentVolumeBlock { + volumeDevices = append(volumeDevices, corev1.VolumeDevice{Name: pvcImporterDataVolName, DevicePath: pvcImporterWriteBlockPath}) + } else { + volumeMounts = append(volumeMounts, corev1.VolumeMount{Name: pvcImporterDataVolName, MountPath: pvcImporterDataDir}) + } + + pod := &corev1.Pod{ + TypeMeta: metav1.TypeMeta{Kind: "Pod", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: target.Namespace, + Labels: map[string]string{ + annotations.QuotaExcludeLabel: annotations.QuotaExcludeValue, + annotations.AppLabel: annotations.CDILabelValue, + }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + Name: target.Name, + UID: target.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + }}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "d8v-pvc-target-importer", + Image: s.image, + ImagePullPolicy: corev1.PullPolicy(s.pullPolicy), + Command: []string{"/usr/bin/pvc-target-importer"}, + Args: []string{"-v=" + s.verbose}, + Env: []corev1.EnvVar{ + {Name: common.OwnerUID, Value: string(ownerUID)}, + {Name: common.ImporterNBDEndpoint, Value: fmt.Sprintf("nbd://%s:%d", sourceNBDHost, pvcImporterNBDPort)}, + }, + Ports: []corev1.ContainerPort{{Name: "metrics", ContainerPort: 8443, Protocol: corev1.ProtocolTCP}}, + VolumeMounts: volumeMounts, + VolumeDevices: volumeDevices, + }}, + RestartPolicy: corev1.RestartPolicyOnFailure, + Volumes: []corev1.Volume{ + {Name: "tmp", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, + {Name: pvcImporterDataVolName, VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: dataPVC.Name}}}, + }, + }, + } + if s.resourceRequirements.Requests != nil || s.resourceRequirements.Limits != nil { + pod.Spec.Containers[0].Resources = s.resourceRequirements + } + if nodePlacement != nil { + pod.Spec.Tolerations = nodePlacement.Tolerations + _ = provisioner.KeepNodePlacementTolerations(nodePlacement, pod) + } + if selectedNode := dataPVC.Annotations[selectedNodeAnnotation]; selectedNode != "" { + pod.Spec.Affinity = &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: corev1.LabelHostname, + Operator: corev1.NodeSelectorOpIn, + Values: []string{selectedNode}, + }}, + }}, + }, + }, + } + } + podutil.SetRestrictedSecurityContext(&pod.Spec) + return pod +} diff --git a/images/virtualization-artifact/pkg/controller/service/pvc_rebind.go b/images/virtualization-artifact/pkg/controller/service/pvc_rebind.go new file mode 100644 index 0000000000..bb9469cda8 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/pvc_rebind.go @@ -0,0 +1,213 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" +) + +// rebindOriginalReclaimPolicyAnnotation stores the PersistentVolume reclaim +// policy that was in effect before Rebind temporarily switched it to Retain, so +// it can be restored once the target PVC is bound. +const rebindOriginalReclaimPolicyAnnotation = "virtualization.deckhouse.io/rebind-original-reclaim-policy" + +// Rebind transfers the PersistentVolume that backs the prime PersistentVolumeClaim +// to the target PersistentVolumeClaim, following the CDI volume-populator pattern, +// and then removes the prime PVC. This lets an importer populate a dedicated prime +// PVC while the target PVC (the one a VirtualMachine consumes) stays untouched, so +// the importer pod and the VM never contend for the same ReadWriteOnce volume. +// +// The operation is idempotent and resumable: call it repeatedly until it returns +// true. The steps are ordered so the populated PV is never lost, even if the +// process crashes between calls: +// +// 1. the PV reclaim policy is switched to Retain (original saved in an annotation); +// 2. the PV ClaimRef is pointed at the target PVC; +// 3. the target PVC VolumeName is pointed at the PV; +// 4. the prime PVC is deleted (safe: the PV now belongs to the target and is Retained); +// 5. once the target PVC is Bound, the original reclaim policy is restored. +// +// Binding itself is completed by the cluster's PersistentVolume binder; Rebind +// returns false until the target PVC reaches the Bound phase. +func Rebind(ctx context.Context, c client.Client, prime, target types.NamespacedName) (bool, error) { + targetPVC, err := object.FetchObject(ctx, target, c, &corev1.PersistentVolumeClaim{}) + if err != nil { + return false, fmt.Errorf("fetch target pvc %s: %w", target, err) + } + if targetPVC == nil { + return false, fmt.Errorf("target pvc %s not found", target) + } + + primePVC, err := object.FetchObject(ctx, prime, c, &corev1.PersistentVolumeClaim{}) + if err != nil { + return false, fmt.Errorf("fetch prime pvc %s: %w", prime, err) + } + + // Resolve the PV to transfer: prefer the one the target already points at + // (resumed run), otherwise the one bound to the prime PVC. + pvName := targetPVC.Spec.VolumeName + if pvName == "" && primePVC != nil { + pvName = primePVC.Spec.VolumeName + } + if pvName == "" { + if primePVC == nil { + return false, fmt.Errorf("cannot rebind: prime pvc %s is gone and target %s is not bound to any volume", prime, target) + } + // Prime PVC exists but has not been provisioned/bound yet; wait. + return false, nil + } + + pv, err := object.FetchObject(ctx, types.NamespacedName{Name: pvName}, c, &corev1.PersistentVolume{}) + if err != nil { + return false, fmt.Errorf("fetch persistent volume %q: %w", pvName, err) + } + if pv == nil { + return false, fmt.Errorf("persistent volume %q not found", pvName) + } + + // Step 1: Retain the PV so deleting the prime PVC can never delete the volume. + if err := updatePersistentVolume(ctx, c, pvName, func(pv *corev1.PersistentVolume) (bool, error) { + if pv.Spec.PersistentVolumeReclaimPolicy == corev1.PersistentVolumeReclaimRetain { + return false, nil + } + if pv.Annotations == nil { + pv.Annotations = map[string]string{} + } + if _, ok := pv.Annotations[rebindOriginalReclaimPolicyAnnotation]; !ok { + pv.Annotations[rebindOriginalReclaimPolicyAnnotation] = string(pv.Spec.PersistentVolumeReclaimPolicy) + } + pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimRetain + return true, nil + }); err != nil { + return false, fmt.Errorf("retain persistent volume %q: %w", pvName, err) + } + + // Step 2: Point the PV at the target PVC. + if err := updatePersistentVolume(ctx, c, pvName, func(pv *corev1.PersistentVolume) (bool, error) { + if pv.Spec.ClaimRef != nil && pv.Spec.ClaimRef.UID == targetPVC.UID { + return false, nil + } + pv.Spec.ClaimRef = &corev1.ObjectReference{ + Kind: "PersistentVolumeClaim", + APIVersion: "v1", + Namespace: targetPVC.Namespace, + Name: targetPVC.Name, + UID: targetPVC.UID, + ResourceVersion: targetPVC.ResourceVersion, + } + return true, nil + }); err != nil { + return false, fmt.Errorf("rebind persistent volume %q claimRef to target: %w", pvName, err) + } + + // Step 3: Point the target PVC at the PV. + if err := updatePersistentVolumeClaim(ctx, c, target, func(pvc *corev1.PersistentVolumeClaim) (bool, error) { + if pvc.Spec.VolumeName == pvName { + return false, nil + } + pvc.Spec.VolumeName = pvName + return true, nil + }); err != nil { + return false, fmt.Errorf("set target pvc %s volumeName: %w", target, err) + } + + // Step 4: Delete the prime PVC. The PV is safe now: its ClaimRef points to + // the target and its reclaim policy is Retain. + if primePVC != nil && primePVC.DeletionTimestamp == nil { + if err := c.Delete(ctx, primePVC); err != nil && !k8serrors.IsNotFound(err) { + return false, fmt.Errorf("delete prime pvc %s: %w", prime, err) + } + } + + // Step 5: The PV binder finalizes the bind. Once the target is Bound, restore + // the original reclaim policy and report completion. + if targetPVC.Status.Phase != corev1.ClaimBound { + return false, nil + } + + if err := restoreReclaimPolicy(ctx, c, pvName); err != nil { + return false, err + } + return true, nil +} + +func updatePersistentVolume(ctx context.Context, c client.Client, name string, mutate func(*corev1.PersistentVolume) (bool, error)) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + current := &corev1.PersistentVolume{} + if err := c.Get(ctx, types.NamespacedName{Name: name}, current); err != nil { + return err + } + changed, err := mutate(current) + if err != nil { + return err + } + if !changed { + return nil + } + return c.Update(ctx, current) + }) +} + +func updatePersistentVolumeClaim(ctx context.Context, c client.Client, key types.NamespacedName, mutate func(*corev1.PersistentVolumeClaim) (bool, error)) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + current := &corev1.PersistentVolumeClaim{} + if err := c.Get(ctx, key, current); err != nil { + return err + } + changed, err := mutate(current) + if err != nil { + return err + } + if !changed { + return nil + } + return c.Update(ctx, current) + }) +} + +// restoreReclaimPolicy reverts the PV reclaim policy to the value saved before +// Rebind switched it to Retain. It is a no-op when no original value was saved. +func restoreReclaimPolicy(ctx context.Context, c client.Client, pvName string) error { + err := updatePersistentVolume(ctx, c, pvName, func(pv *corev1.PersistentVolume) (bool, error) { + original, ok := pv.Annotations[rebindOriginalReclaimPolicyAnnotation] + if !ok || original == "" || original == string(corev1.PersistentVolumeReclaimRetain) { + // Nothing to restore (it was Retain to begin with, or already restored). + if ok { + delete(pv.Annotations, rebindOriginalReclaimPolicyAnnotation) + return true, nil + } + return false, nil + } + + pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimPolicy(original) + delete(pv.Annotations, rebindOriginalReclaimPolicyAnnotation) + return true, nil + }) + if err != nil { + return fmt.Errorf("restore persistent volume %q reclaim policy: %w", pvName, err) + } + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/service/pvc_rebind_test.go b/images/virtualization-artifact/pkg/controller/service/pvc_rebind_test.go new file mode 100644 index 0000000000..a43a356933 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/pvc_rebind_test.go @@ -0,0 +1,289 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const ( + rebindNS = "ns" + rebindPrimeNm = "prime" + rebindTargetNm = "target" + rebindPVNm = "pv-1" +) + +func rebindClient(objs ...client.Object) client.Client { + return fake.NewClientBuilder(). + WithScheme(rebindScheme()). + WithStatusSubresource(&corev1.PersistentVolumeClaim{}, &corev1.PersistentVolume{}). + WithObjects(objs...). + Build() +} + +func rebindScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + return scheme +} + +func newBoundPVC(name string, uid types.UID) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: rebindNS, UID: uid}, + Spec: corev1.PersistentVolumeClaimSpec{VolumeName: rebindPVNm}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } +} + +func newPendingPVC(name string, uid types.UID) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: rebindNS, UID: uid}, + Spec: corev1.PersistentVolumeClaimSpec{}, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimPending}, + } +} + +func newPV(claimRefName string, claimRefUID types.UID, reclaim corev1.PersistentVolumeReclaimPolicy) *corev1.PersistentVolume { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: rebindPVNm}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: reclaim, + }, + } + if claimRefName != "" { + pv.Spec.ClaimRef = &corev1.ObjectReference{ + Kind: "PersistentVolumeClaim", + Namespace: rebindNS, + Name: claimRefName, + UID: claimRefUID, + } + } + return pv +} + +func getPVC(t *testing.T, c client.Client, name string) *corev1.PersistentVolumeClaim { + t.Helper() + pvc := &corev1.PersistentVolumeClaim{} + err := c.Get(context.Background(), types.NamespacedName{Namespace: rebindNS, Name: name}, pvc) + if err != nil { + t.Fatalf("get pvc %s: %v", name, err) + } + return pvc +} + +func getPV(t *testing.T, c client.Client, name string) *corev1.PersistentVolume { + t.Helper() + pv := &corev1.PersistentVolume{} + if err := c.Get(context.Background(), types.NamespacedName{Name: name}, pv); err != nil { + t.Fatalf("get pv %s: %v", name, err) + } + return pv +} + +func primeKey() types.NamespacedName { + return types.NamespacedName{Namespace: rebindNS, Name: rebindPrimeNm} +} + +func targetKey() types.NamespacedName { + return types.NamespacedName{Namespace: rebindNS, Name: rebindTargetNm} +} + +// TestRebindTransfersPVAndDeletesPrime covers the first (not-yet-bound) pass: +// the PV is retained and rebound to the target, the target points at the PV, and +// the prime PVC is deleted. Binding is finalized by the cluster binder afterwards, +// so Rebind returns false. +func TestRebindTransfersPVAndDeletesPrime(t *testing.T) { + prime := newBoundPVC(rebindPrimeNm, "prime-uid") + target := newPendingPVC(rebindTargetNm, "target-uid") + pv := newPV(rebindPrimeNm, "prime-uid", corev1.PersistentVolumeReclaimDelete) + c := rebindClient(prime, target, pv) + + done, err := Rebind(context.Background(), c, primeKey(), targetKey()) + if err != nil { + t.Fatalf("Rebind returned error: %v", err) + } + if done { + t.Fatalf("expected done=false while target is not yet Bound") + } + + gotPV := getPV(t, c, rebindPVNm) + if gotPV.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain { + t.Errorf("expected PV reclaim policy Retain, got %q", gotPV.Spec.PersistentVolumeReclaimPolicy) + } + if got := gotPV.Annotations[rebindOriginalReclaimPolicyAnnotation]; got != string(corev1.PersistentVolumeReclaimDelete) { + t.Errorf("expected saved original reclaim policy Delete, got %q", got) + } + if gotPV.Spec.ClaimRef == nil || gotPV.Spec.ClaimRef.Name != rebindTargetNm || gotPV.Spec.ClaimRef.UID != "target-uid" { + t.Errorf("expected PV claimRef pointing to target, got %#v", gotPV.Spec.ClaimRef) + } + + gotTarget := getPVC(t, c, rebindTargetNm) + if gotTarget.Spec.VolumeName != rebindPVNm { + t.Errorf("expected target volumeName %q, got %q", rebindPVNm, gotTarget.Spec.VolumeName) + } + + if err := c.Get(context.Background(), primeKey(), &corev1.PersistentVolumeClaim{}); !k8serrors.IsNotFound(err) { + t.Errorf("expected prime PVC to be deleted, got err=%v", err) + } +} + +// TestRebindCompletesWhenTargetBound covers the final pass: once the binder has +// bound the target, Rebind restores the original reclaim policy and reports done. +func TestRebindCompletesWhenTargetBound(t *testing.T) { + // Target already bound to the PV (binder finished). + target := newBoundPVC(rebindTargetNm, "target-uid") + pv := newPV(rebindTargetNm, "target-uid", corev1.PersistentVolumeReclaimRetain) + pv.Annotations = map[string]string{rebindOriginalReclaimPolicyAnnotation: string(corev1.PersistentVolumeReclaimDelete)} + // Prime already gone. + c := rebindClient(target, pv) + + done, err := Rebind(context.Background(), c, primeKey(), targetKey()) + if err != nil { + t.Fatalf("Rebind returned error: %v", err) + } + if !done { + t.Fatalf("expected done=true when target is Bound") + } + + gotPV := getPV(t, c, rebindPVNm) + if gotPV.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimDelete { + t.Errorf("expected reclaim policy restored to Delete, got %q", gotPV.Spec.PersistentVolumeReclaimPolicy) + } + if _, ok := gotPV.Annotations[rebindOriginalReclaimPolicyAnnotation]; ok { + t.Errorf("expected original-reclaim-policy annotation to be removed") + } +} + +// TestRebindEndToEnd drives the full lifecycle through repeated calls, simulating +// the PV binder between calls, and asserts idempotency. +func TestRebindEndToEnd(t *testing.T) { + prime := newBoundPVC(rebindPrimeNm, "prime-uid") + target := newPendingPVC(rebindTargetNm, "target-uid") + pv := newPV(rebindPrimeNm, "prime-uid", corev1.PersistentVolumeReclaimDelete) + c := rebindClient(prime, target, pv) + ctx := context.Background() + + // Pass 1: rebind spec changes, prime deleted, not yet bound. + done, err := Rebind(ctx, c, primeKey(), targetKey()) + if err != nil || done { + t.Fatalf("pass 1: done=%v err=%v, want false/nil", done, err) + } + + // Pass 2 (idempotent re-run before the binder acts): still not bound, no error. + done, err = Rebind(ctx, c, primeKey(), targetKey()) + if err != nil || done { + t.Fatalf("pass 2 (idempotent): done=%v err=%v, want false/nil", done, err) + } + + // Simulate the cluster PV binder finishing the bind (status subresource). + gotTarget := getPVC(t, c, rebindTargetNm) + gotTarget.Status.Phase = corev1.ClaimBound + if err := c.Status().Update(ctx, gotTarget); err != nil { + t.Fatalf("simulate binder: %v", err) + } + + // Pass 3: target Bound -> completion, reclaim policy restored. + done, err = Rebind(ctx, c, primeKey(), targetKey()) + if err != nil { + t.Fatalf("pass 3: err=%v", err) + } + if !done { + t.Fatalf("pass 3: expected done=true after target Bound") + } + + gotPV := getPV(t, c, rebindPVNm) + if gotPV.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimDelete { + t.Errorf("expected reclaim policy restored to Delete, got %q", gotPV.Spec.PersistentVolumeReclaimPolicy) + } + if gotPV.Spec.ClaimRef == nil || gotPV.Spec.ClaimRef.UID != "target-uid" { + t.Errorf("expected PV claimRef to target, got %#v", gotPV.Spec.ClaimRef) + } + + // Pass 4: fully idempotent once done. + done, err = Rebind(ctx, c, primeKey(), targetKey()) + if err != nil || !done { + t.Fatalf("pass 4 (idempotent done): done=%v err=%v, want true/nil", done, err) + } +} + +// TestRebindAlreadyDone returns true immediately when the target is already bound +// and the prime PVC no longer exists. +func TestRebindAlreadyDone(t *testing.T) { + target := newBoundPVC(rebindTargetNm, "target-uid") + pv := newPV(rebindTargetNm, "target-uid", corev1.PersistentVolumeReclaimDelete) + c := rebindClient(target, pv) + + done, err := Rebind(context.Background(), c, primeKey(), targetKey()) + if err != nil || !done { + t.Fatalf("done=%v err=%v, want true/nil", done, err) + } +} + +// TestRebindWaitsWhenPrimeNotBound returns (false, nil) without touching anything +// while the prime PVC has not been provisioned yet. +func TestRebindWaitsWhenPrimeNotBound(t *testing.T) { + prime := newPendingPVC(rebindPrimeNm, "prime-uid") // no VolumeName yet + target := newPendingPVC(rebindTargetNm, "target-uid") + c := rebindClient(prime, target) + + done, err := Rebind(context.Background(), c, primeKey(), targetKey()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if done { + t.Fatalf("expected done=false while prime is not bound") + } + // Prime must still exist. + if err := c.Get(context.Background(), primeKey(), &corev1.PersistentVolumeClaim{}); err != nil { + t.Errorf("prime PVC must not be deleted while unbound: %v", err) + } +} + +func TestRebindErrors(t *testing.T) { + t.Run("target not found", func(t *testing.T) { + c := rebindClient(newBoundPVC(rebindPrimeNm, "prime-uid")) + if _, err := Rebind(context.Background(), c, primeKey(), targetKey()); err == nil { + t.Fatal("expected error when target PVC is missing") + } + }) + + t.Run("prime gone and target not bound", func(t *testing.T) { + c := rebindClient(newPendingPVC(rebindTargetNm, "target-uid")) + if _, err := Rebind(context.Background(), c, primeKey(), targetKey()); err == nil { + t.Fatal("expected error when prime is gone and target is unbound") + } + }) + + t.Run("pv not found", func(t *testing.T) { + c := rebindClient( + newBoundPVC(rebindPrimeNm, "prime-uid"), + newPendingPVC(rebindTargetNm, "target-uid"), + ) + if _, err := Rebind(context.Background(), c, primeKey(), targetKey()); err == nil { + t.Fatal("expected error when the backing PV is missing") + } + }) +} diff --git a/images/virtualization-artifact/pkg/controller/service/stat_service.go b/images/virtualization-artifact/pkg/controller/service/stat_service.go index da86d95b24..91de44a6ae 100644 --- a/images/virtualization-artifact/pkg/controller/service/stat_service.go +++ b/images/virtualization-artifact/pkg/controller/service/stat_service.go @@ -21,6 +21,7 @@ import ( "crypto/x509" "errors" "fmt" + "math" "net/http" "regexp" "strconv" @@ -182,7 +183,7 @@ func (s StatService) GetDownloadSpeed(ownerUID types.UID, pod *corev1.Pod) *v1al progress, err := monitoring.GetImportProgressFromPod(string(ownerUID), pod) if err != nil { - s.logger.Error("GetDownloadSpeed: Cannot get import progress from pod", "err", err) + s.logger.Warn("GetDownloadSpeed: Cannot get import progress from pod", "err", err) return nil } @@ -218,6 +219,14 @@ func (o ScaleOption) Apply(progress string) string { return percent.ScalePercentage(progress, o.Low, o.High) } +func CapProgressBelow(progress string, high float64) string { + value := percent.ExtractPercentageFloat(progress) + if math.IsNaN(value) || value < high { + return progress + } + return percent.Format(high - 0.1) +} + func (s StatService) GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...GetProgressOption) string { if pod == nil { return prevProgress @@ -244,7 +253,7 @@ func (s StatService) GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgre progress, err := monitoring.GetImportProgressFromPod(string(ownerUID), pod) if err != nil { - s.logger.Error("GetProgress: Cannot get import progress from pod", "err", err) + s.logger.Warn("GetProgress: Cannot get import progress from pod", "err", err) return prevProgress } @@ -257,13 +266,32 @@ func (s StatService) GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgre res = o.Apply(res) } - return res + // Keep the reported progress monotonic: an importer pod that restarts + // (RestartPolicy=OnFailure) resets its kubevirt_cdi_import_progress_total + // metric to 0, which would otherwise make progress jump backwards (e.g. + // 46.4% -> 0%). Never report a value lower than the one already published. + return maxProgress(prevProgress, res) +} + +// maxProgress returns whichever of prev/next represents the larger percentage so +// reported progress never moves backwards. If either value cannot be parsed as a +// percentage, next is returned unchanged. +func maxProgress(prev, next string) string { + prevVal := percent.ExtractPercentageFloat(prev) + nextVal := percent.ExtractPercentageFloat(next) + if math.IsNaN(prevVal) || math.IsNaN(nextVal) { + return next + } + if prevVal > nextVal { + return prev + } + return next } func (s StatService) IsImportStarted(ownerUID types.UID, pod *corev1.Pod) bool { progress, err := monitoring.GetImportProgressFromPod(string(ownerUID), pod) if err != nil { - s.logger.Error("IsImportStarted: Cannot get import progress from pod", "err", err) + s.logger.Warn("IsImportStarted: Cannot get import progress from pod", "err", err) return false } @@ -283,6 +311,21 @@ func (s StatService) IsUploaderReady(pod *corev1.Pod, svc *corev1.Service, ing * return false, nil } + if svc.Spec.ClusterIP != "" { + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Get(fmt.Sprintf("http://%s/upload", svc.Spec.ClusterIP)) + if err != nil { + return false, nil + } + defer response.Body.Close() + + if response.StatusCode == http.StatusOK { + return true, nil + } + + return false, nil + } + uploadURL, ok := ing.Annotations[annotations.AnnUploadURL] if ok && uploadURL != "" { certPool, err := x509.SystemCertPool() diff --git a/images/virtualization-artifact/pkg/controller/storageprofile/capabilities.go b/images/virtualization-artifact/pkg/controller/storageprofile/capabilities.go new file mode 100644 index 0000000000..b6ca1ec628 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/storageprofile/capabilities.go @@ -0,0 +1,241 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package storageprofile + +import ( + corev1 "k8s.io/api/core/v1" + cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" +) + +// storageCapability is a supported (accessMode, volumeMode) pair of a provisioner. +type storageCapability struct { + accessMode corev1.PersistentVolumeAccessMode + volumeMode corev1.PersistentVolumeMode +} + +// SDSReplicatedCSIProvisioner is the Deckhouse SDS Replicated CSI driver. +const SDSReplicatedCSIProvisioner = "replicated.csi.storage.deckhouse.io" + +var ( + rwx = corev1.ReadWriteMany + rwo = corev1.ReadWriteOnce + rox = corev1.ReadOnlyMany + file = corev1.PersistentVolumeFilesystem + block = corev1.PersistentVolumeBlock +) + +// capabilitiesByProvisioner mirrors CDI's storagecapabilities.CapabilitiesByProvisionerKey. +// CDI has been removed from this module, but the per-provisioner defaults are still required: +// without them every StorageClass would be reported as ReadWriteOnce only, which (for example) +// forces VirtualDisk PVCs backed by replicated.csi.storage.deckhouse.io to ReadWriteOnce even +// though the driver supports ReadWriteMany/Block. That breaks importing a WaitForFirstConsumer +// disk that a VirtualMachine consumes at the same time (the importer pod and the VM pod end up +// on different nodes and cannot share a ReadWriteOnce volume). +// +// The order matters: the first entry is the most preferred capability and is the one selected +// by the disk provisioning logic (see service/volumemode). +var capabilitiesByProvisioner = map[string][]storageCapability{ + // hostpath-provisioner + "kubevirt.io.hostpath-provisioner": {{rwo, file}}, + "kubevirt.io/hostpath-provisioner": {{rwo, file}}, + "k8s.io/minikube-hostpath": {{rwo, file}}, + // nfs-csi + "nfs.csi.k8s.io": {{rwx, file}}, + "k8s-sigs.io/nfs-subdir-external-provisioner": {{rwx, file}}, + // ceph-rbd + "kubernetes.io/rbd": createRbdCapabilities(), + "rbd.csi.ceph.com": createRbdCapabilities(), + "rook-ceph.rbd.csi.ceph.com": createRbdCapabilities(), + "openshift-storage.rbd.csi.ceph.com": createRbdCapabilities(), + // ceph-fs + "cephfs.csi.ceph.com": {{rwx, file}}, + "openshift-storage.cephfs.csi.ceph.com": {{rwx, file}}, + // LINSTOR + "linstor.csi.linbit.com": createAllButRWXFileCapabilities(), + // Deckhouse + SDSReplicatedCSIProvisioner: createAllButRWXFileCapabilities(), + "local.csi.storage.deckhouse.io": createTopoLVMCapabilities(), + "scsi-generic.csi.storage.deckhouse.io": createAllButRWXFileCapabilities(), + // DELL Unity XT + "csi-unity.dellemc.com": createAllButRWXFileCapabilities(), + "csi-unity.dellemc.com/nfs": createAllFSCapabilities(), + // DELL PowerFlex + "csi-vxflexos.dellemc.com": createDellPowerFlexCapabilities(), + "csi-vxflexos.dellemc.com/nfs": createAllFSCapabilities(), + // DELL PowerScale + "csi-isilon.dellemc.com": createAllFSCapabilities(), + // DELL PowerMax + "csi-powermax.dellemc.com": createDellPowerMaxCapabilities(), + "csi-powermax.dellemc.com/nfs": createAllFSCapabilities(), + // DELL PowerStore + "csi-powerstore.dellemc.com": createDellPowerStoreCapabilities(), + "csi-powerstore.dellemc.com/nfs": createAllFSCapabilities(), + // storageos + "kubernetes.io/storageos": {{rwo, file}}, + "storageos": {{rwo, file}}, + // AWSElasticBlockStore + "kubernetes.io/aws-ebs": {{rwo, block}}, + "ebs.csi.aws.com": {{rwo, block}}, + // AWSElasticFileSystem + "efs.csi.aws.com": {{rwx, file}, {rwo, file}}, + // Azure disk + "kubernetes.io/azure-disk": {{rwo, block}}, + "disk.csi.azure.com": {{rwo, block}}, + // Azure file + "kubernetes.io/azure-file": {{rwx, file}}, + "file.csi.azure.com": {{rwx, file}}, + // GCE Persistent Disk + "kubernetes.io/gce-pd": {{rwo, block}}, + "pd.csi.storage.gke.io": {{rwo, block}}, + // Hitachi + "hspc.csi.hitachi.com": {{rwx, block}, {rwo, block}, {rwo, file}}, + // HPE + "csi.hpe.com": {{rwx, block}, {rwo, block}, {rwo, file}}, + // IBM HCI/GPFS2 (Spectrum Scale / Spectrum Fusion) + "spectrumscale.csi.ibm.com": {{rwx, file}, {rwo, file}}, + // IBM block arrays (FlashSystem) + "block.csi.ibm.com": {{rwo, block}, {rwo, file}}, + // Portworx in-tree CSI + "kubernetes.io/portworx-volume/shared": {{rwx, file}}, + "kubernetes.io/portworx-volume": {{rwo, file}}, + // Portworx CSI + "pxd.openstorage.org/shared": createOpenStorageSharedVolumeCapabilities(), + "pxd.openstorage.org": createOpenStorageSharedVolumeCapabilities(), + "pxd.portworx.com/shared": createOpenStorageSharedVolumeCapabilities(), + "pxd.portworx.com": createOpenStorageSharedVolumeCapabilities(), + // Trident + "csi.trident.netapp.io/ontap-nas": {{rwx, file}, {rwo, file}}, + "csi.trident.netapp.io/ontap-san": {{rwx, block}}, + // topolvm + "topolvm.cybozu.com": createTopoLVMCapabilities(), + "topolvm.io": createTopoLVMCapabilities(), + // OpenStack Cinder + "cinder.csi.openstack.org": createRWOBlockAndFilesystemCapabilities(), + // OpenStack manila + "manila.csi.openstack.org": {{rwx, file}}, + // ovirt csi + "csi.ovirt.org": createRWOBlockAndFilesystemCapabilities(), + // vSphere + "csi.vsphere.vmware.com": {{rwo, block}, {rwo, file}}, + "csi.vsphere.vmware.com/nfs": {{rwx, file}, {rwo, block}, {rwo, file}}, + // huawei + "csi.huawei.com": createAllButRWXFileCapabilities(), + "csi.huawei.com/nfs": createAllFSCapabilities(), + // KubeSAN + "kubesan.gitlab.io": {{rwx, block}, {rox, block}, {rwo, block}, {rwo, file}}, + // Longhorn + "driver.longhorn.io": {{rwo, block}}, + "driver.longhorn.io/migratable": {{rwx, block}, {rwo, block}}, + // Yadro Tatlin + "csi-tatlinunified.yadro.com": createAllButRWXFileCapabilities(), +} + +// claimPropertySetsForProvisioner returns the advised claim property sets for a provisioner, +// preserving preference order. The boolean is false when the provisioner is unknown, in which +// case the caller should fall back to a conservative default. +func claimPropertySetsForProvisioner(provisioner string) ([]cdiv1.ClaimPropertySet, bool) { + caps, ok := capabilitiesByProvisioner[provisioner] + if !ok { + return nil, false + } + + sets := make([]cdiv1.ClaimPropertySet, 0, len(caps)) + for i := range caps { + volumeMode := caps[i].volumeMode + sets = append(sets, cdiv1.ClaimPropertySet{ + AccessModes: []corev1.PersistentVolumeAccessMode{caps[i].accessMode}, + VolumeMode: &volumeMode, + }) + } + return sets, true +} + +func createRbdCapabilities() []storageCapability { + return []storageCapability{ + {rwx, block}, + {rwo, block}, + {rwo, file}, + } +} + +func createAllButRWXFileCapabilities() []storageCapability { + return []storageCapability{ + {rwx, block}, + {rwo, block}, + {rwo, file}, + {rox, block}, + {rox, file}, + } +} + +func createDellPowerMaxCapabilities() []storageCapability { + return []storageCapability{ + {rwx, block}, + {rwo, block}, + {rwo, file}, + {rox, block}, + } +} + +func createDellPowerFlexCapabilities() []storageCapability { + return []storageCapability{ + {rwx, block}, + {rwo, block}, + {rwo, file}, + {rox, block}, + {rox, file}, + } +} + +func createDellPowerStoreCapabilities() []storageCapability { + return []storageCapability{ + {rwx, block}, + {rwo, block}, + {rwo, file}, + {rox, block}, + } +} + +func createAllFSCapabilities() []storageCapability { + return []storageCapability{ + {rwx, file}, + {rwo, file}, + {rox, file}, + } +} + +func createTopoLVMCapabilities() []storageCapability { + return []storageCapability{ + {rwo, block}, + {rwo, file}, + } +} + +func createOpenStorageSharedVolumeCapabilities() []storageCapability { + return []storageCapability{ + {rwx, file}, + {rwo, block}, + {rwo, file}, + } +} + +func createRWOBlockAndFilesystemCapabilities() []storageCapability { + return []storageCapability{ + {rwo, block}, + {rwo, file}, + } +} diff --git a/images/virtualization-artifact/pkg/controller/storageprofile/storageprofile_controller.go b/images/virtualization-artifact/pkg/controller/storageprofile/storageprofile_controller.go new file mode 100644 index 0000000000..618ff9eda2 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/storageprofile/storageprofile_controller.go @@ -0,0 +1,250 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package storageprofile + +import ( + "context" + "fmt" + "reflect" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/logger" +) + +const ControllerName = "storageprofile-controller" + +type Reconciler struct { + client client.Client + log *log.Logger +} + +func NewController(mgr manager.Manager, log *log.Logger) (controller.Controller, error) { + reconciler := &Reconciler{client: mgr.GetClient(), log: log} + ctr, err := controller.New(ControllerName, mgr, controller.Options{ + Reconciler: reconciler, + LogConstructor: logger.NewConstructor(log), + }) + if err != nil { + return nil, err + } + if err := addWatches(mgr, ctr); err != nil { + return nil, err + } + log.Info("Initialized StorageProfile controller") + return ctr, nil +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + sc := &storagev1.StorageClass{} + if err := r.client.Get(ctx, req.NamespacedName, sc); err != nil { + if k8serrors.IsNotFound(err) { + return reconcile.Result{}, r.deleteStorageProfile(ctx, req.Name) + } + return reconcile.Result{}, err + } + if sc.DeletionTimestamp != nil { + return reconcile.Result{}, r.deleteStorageProfile(ctx, req.Name) + } + return reconcile.Result{}, r.reconcileStorageProfile(ctx, sc) +} + +func (r *Reconciler) reconcileStorageProfile(ctx context.Context, sc *storagev1.StorageClass) error { + profile := &cdiv1.StorageProfile{} + var previous *cdiv1.StorageProfile + if err := r.client.Get(ctx, types.NamespacedName{Name: sc.Name}, profile); err != nil { + if !k8serrors.IsNotFound(err) { + return err + } + profile = emptyStorageProfile(sc.Name) + } else { + previous = profile.DeepCopy() + } + + profile.Status.StorageClass = &sc.Name + profile.Status.Provisioner = &sc.Provisioner + snapshotClass := r.snapshotClassForProvisioner(ctx, sc.Provisioner, profile.Spec.SnapshotClass) + if snapshotClass == "" { + profile.Status.SnapshotClass = nil + } else { + profile.Status.SnapshotClass = &snapshotClass + } + profile.Status.CloneStrategy = reconcileCloneStrategy(sc, profile.Spec.CloneStrategy, snapshotClass) + profile.Status.DataImportCronSourceFormat = reconcileDataImportCronSourceFormat(profile.Spec.DataImportCronSourceFormat) + if len(profile.Spec.ClaimPropertySets) > 0 { + profile.Status.ClaimPropertySets = profile.Spec.ClaimPropertySets + } else if sets, ok := claimPropertySetsForProvisioner(sc.Provisioner); ok { + profile.Status.ClaimPropertySets = sets + } else { + profile.Status.ClaimPropertySets = defaultClaimPropertySets() + } + + if previous == nil { + return r.client.Create(ctx, profile) + } + if !reflect.DeepEqual(previous, profile) { + return r.client.Update(ctx, profile) + } + return nil +} + +func (r *Reconciler) deleteStorageProfile(ctx context.Context, name string) error { + err := r.client.Delete(ctx, &cdiv1.StorageProfile{ObjectMeta: metav1.ObjectMeta{Name: name}}) + if err != nil && !k8serrors.IsNotFound(err) { + return err + } + return nil +} + +func (r *Reconciler) snapshotClassForProvisioner(ctx context.Context, provisioner string, desired *string) string { + var list vsv1.VolumeSnapshotClassList + if err := r.client.List(ctx, &list); err != nil { + return "" + } + if desired != nil && *desired != "" { + for _, item := range list.Items { + if item.Name == *desired && item.Driver == provisioner { + return item.Name + } + } + return "" + } + for _, item := range list.Items { + if item.Driver == provisioner { + return item.Name + } + } + return "" +} + +func reconcileCloneStrategy(sc *storagev1.StorageClass, desired *cdiv1.CDICloneStrategy, snapshotClass string) *cdiv1.CDICloneStrategy { + if desired != nil { + return desired + } + if value, ok := sc.Annotations["cdi.kubevirt.io/clone-strategy"]; ok { + switch value { + case "copy": + strategy := cdiv1.CloneStrategyHostAssisted + return &strategy + case "snapshot": + strategy := cdiv1.CloneStrategySnapshot + return &strategy + case "csi-clone": + strategy := cdiv1.CloneStrategyCsiClone + return &strategy + } + } + if snapshotClass != "" { + strategy := cdiv1.CloneStrategySnapshot + return &strategy + } + strategy := cdiv1.CloneStrategyHostAssisted + return &strategy +} + +func reconcileDataImportCronSourceFormat(desired *cdiv1.DataImportCronSourceFormat) *cdiv1.DataImportCronSourceFormat { + if desired != nil { + return desired + } + format := cdiv1.DataImportCronSourceFormatPvc + return &format +} + +func defaultClaimPropertySets() []cdiv1.ClaimPropertySet { + fs := corev1.PersistentVolumeFilesystem + block := corev1.PersistentVolumeBlock + return []cdiv1.ClaimPropertySet{ + {AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, VolumeMode: &fs}, + {AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, VolumeMode: &block}, + } +} + +func emptyStorageProfile(name string) *cdiv1.StorageProfile { + return &cdiv1.StorageProfile{ + TypeMeta: metav1.TypeMeta{Kind: "StorageProfile", APIVersion: "cdi.kubevirt.io/v1beta1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "virtualization-controller", + }, + }, + } +} + +func addWatches(mgr manager.Manager, ctr controller.Controller) error { + if err := ctr.Watch(source.Kind(mgr.GetCache(), &storagev1.StorageClass{}, &handler.TypedEnqueueRequestForObject[*storagev1.StorageClass]{})); err != nil { + return err + } + if err := ctr.Watch(source.Kind(mgr.GetCache(), &cdiv1.StorageProfile{}, &handler.TypedEnqueueRequestForObject[*cdiv1.StorageProfile]{})); err != nil { + return err + } + if err := ctr.Watch(source.Kind(mgr.GetCache(), &corev1.PersistentVolume{}, + handler.TypedEnqueueRequestsFromMapFunc(func(_ context.Context, pv *corev1.PersistentVolume) []reconcile.Request { + if pv.Spec.StorageClassName == "" { + return nil + } + return []reconcile.Request{{NamespacedName: types.NamespacedName{Name: pv.Spec.StorageClassName}}} + }), + predicate.TypedFuncs[*corev1.PersistentVolume]{ + CreateFunc: func(e event.TypedCreateEvent[*corev1.PersistentVolume]) bool { + return e.Object.Spec.StorageClassName != "" && e.Object.Spec.HostPath != nil + }, + UpdateFunc: func(e event.TypedUpdateEvent[*corev1.PersistentVolume]) bool { + return e.ObjectNew.Spec.StorageClassName != "" && e.ObjectNew.Spec.HostPath != nil + }, + DeleteFunc: func(e event.TypedDeleteEvent[*corev1.PersistentVolume]) bool { + return e.Object.Spec.StorageClassName != "" && e.Object.Spec.HostPath != nil + }, + }, + )); err != nil { + return err + } + if err := ctr.Watch(source.Kind(mgr.GetCache(), &vsv1.VolumeSnapshotClass{}, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, vsc *vsv1.VolumeSnapshotClass) []reconcile.Request { + var scs storagev1.StorageClassList + if err := mgr.GetClient().List(ctx, &scs); err != nil { + ctr.GetLogger().Error(err, "Unable to list StorageClasses") + return nil + } + var requests []reconcile.Request + for _, sc := range scs.Items { + if sc.Provisioner == vsc.Driver { + requests = append(requests, reconcile.Request{NamespacedName: types.NamespacedName{Name: sc.Name}}) + } + } + return requests + }), + )); err != nil { + return fmt.Errorf("watch VolumeSnapshotClass: %w", err) + } + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/storageprofile/storageprofile_controller_test.go b/images/virtualization-artifact/pkg/controller/storageprofile/storageprofile_controller_test.go new file mode 100644 index 0000000000..b634085704 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/storageprofile/storageprofile_controller_test.go @@ -0,0 +1,135 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package storageprofile + +import ( + "context" + "testing" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func TestReconcileStorageProfileCreatesSnapshotStrategy(t *testing.T) { + ctx := context.Background() + scheme := storageProfileTestScheme(t) + sc := &storagev1.StorageClass{Provisioner: "csi.example.com"} + sc.Name = "fast" + vsc := &vsv1.VolumeSnapshotClass{Driver: sc.Provisioner} + vsc.Name = "snap-fast" + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc, vsc).Build() + r := &Reconciler{client: c} + + _, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: sc.Name}}) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + + sp := &cdiv1.StorageProfile{} + if err := c.Get(ctx, types.NamespacedName{Name: sc.Name}, sp); err != nil { + t.Fatalf("storageprofile not found: %v", err) + } + if sp.Status.CloneStrategy == nil || *sp.Status.CloneStrategy != cdiv1.CloneStrategySnapshot { + t.Fatalf("unexpected clone strategy: %#v", sp.Status.CloneStrategy) + } + if sp.Status.SnapshotClass == nil || *sp.Status.SnapshotClass != vsc.Name { + t.Fatalf("unexpected snapshot class: %#v", sp.Status.SnapshotClass) + } +} + +func TestReconcileStorageProfileUsesSnapshotForSDSReplicated(t *testing.T) { + ctx := context.Background() + scheme := storageProfileTestScheme(t) + sc := &storagev1.StorageClass{Provisioner: SDSReplicatedCSIProvisioner} + sc.Name = "rv-thin-r1" + vsc := &vsv1.VolumeSnapshotClass{Driver: sc.Provisioner} + vsc.Name = "sds-replicated-volume" + snapshot := cdiv1.CloneStrategySnapshot + existing := &cdiv1.StorageProfile{ + ObjectMeta: metav1.ObjectMeta{Name: sc.Name}, + Spec: cdiv1.StorageProfileSpec{ + CloneStrategy: &snapshot, + }, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc, vsc, existing).Build() + r := &Reconciler{client: c} + + _, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: sc.Name}}) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + + sp := &cdiv1.StorageProfile{} + if err := c.Get(ctx, types.NamespacedName{Name: sc.Name}, sp); err != nil { + t.Fatalf("storageprofile not found: %v", err) + } + if sp.Spec.CloneStrategy == nil || *sp.Spec.CloneStrategy != cdiv1.CloneStrategySnapshot { + t.Fatalf("spec clone strategy must stay snapshot: %#v", sp.Spec.CloneStrategy) + } + if sp.Status.CloneStrategy == nil || *sp.Status.CloneStrategy != cdiv1.CloneStrategySnapshot { + t.Fatalf("unexpected clone strategy: %#v", sp.Status.CloneStrategy) + } + if sp.Status.SnapshotClass == nil || *sp.Status.SnapshotClass != vsc.Name { + t.Fatalf("unexpected snapshot class: %#v", sp.Status.SnapshotClass) + } +} + +func TestReconcileStorageProfileHonorsStorageClassAnnotation(t *testing.T) { + ctx := context.Background() + scheme := storageProfileTestScheme(t) + sc := &storagev1.StorageClass{ + Provisioner: "csi.example.com", + } + sc.Name = "fast" + sc.Annotations = map[string]string{"cdi.kubevirt.io/clone-strategy": "csi-clone"} + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc).Build() + r := &Reconciler{client: c} + + _, err := r.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: sc.Name}}) + if err != nil { + t.Fatalf("reconcile failed: %v", err) + } + + sp := &cdiv1.StorageProfile{} + if err := c.Get(ctx, types.NamespacedName{Name: sc.Name}, sp); err != nil { + t.Fatalf("storageprofile not found: %v", err) + } + if sp.Status.CloneStrategy == nil || *sp.Status.CloneStrategy != cdiv1.CloneStrategyCsiClone { + t.Fatalf("unexpected clone strategy: %#v", sp.Status.CloneStrategy) + } +} + +func storageProfileTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := storagev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := vsv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := cdiv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + return scheme +} diff --git a/images/virtualization-artifact/pkg/controller/supplements/ensure.go b/images/virtualization-artifact/pkg/controller/supplements/ensure.go index 8a9a62b0e0..7278182751 100644 --- a/images/virtualization-artifact/pkg/controller/supplements/ensure.go +++ b/images/virtualization-artifact/pkg/controller/supplements/ensure.go @@ -22,15 +22,11 @@ import ( corev1 "k8s.io/api/core/v1" netv1 "k8s.io/api/networking/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/common/datasource" - dvutil "github.com/deckhouse/virtualization-controller/pkg/common/datavolume" ingutil "github.com/deckhouse/virtualization-controller/pkg/common/ingress" - "github.com/deckhouse/virtualization-controller/pkg/common/object" podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements/copier" "github.com/deckhouse/virtualization-controller/pkg/dvcr" @@ -127,66 +123,6 @@ func ShouldCopyImagePullSecret(ctrImg *datasource.ContainerRegistry, targetNS st return imgPullNS != "" && imgPullNS != targetNS } -func EnsureForDataVolume(ctx context.Context, client client.Client, supGen DataVolumeSupplement, dv *cdiv1.DataVolume, dvcrSettings *dvcr.Settings) error { - if dvcrSettings.AuthSecret != "" { - authSecret := supGen.DVCRAuthSecretForDV() - authCopier := copier.AuthSecret{ - Secret: copier.Secret{ - Source: types.NamespacedName{ - Name: dvcrSettings.AuthSecret, - Namespace: dvcrSettings.AuthSecretNamespace, - }, - Destination: authSecret, - OwnerReference: dvutil.MakeOwnerReference(dv), - }, - } - - err := authCopier.CopyCDICompatible(ctx, client, dvcrSettings.RegistryURL) - if err != nil { - return err - } - } - - // CABundle needs transformation, so it always copied. - if dvcrSettings.CertsSecret != "" { - caBundleCM := supGen.DVCRCABundleConfigMapForDV() - caBundleCopier := copier.CABundleConfigMap{ - SourceSecret: types.NamespacedName{ - Name: dvcrSettings.CertsSecret, - Namespace: dvcrSettings.CertsSecretNamespace, - }, - Destination: caBundleCM, - OwnerReference: dvutil.MakeOwnerReference(dv), - } - - return caBundleCopier.Copy(ctx, client) - } - - return nil -} - -func CleanupForDataVolume(ctx context.Context, client client.Client, supGen Generator, dvcrSettings *dvcr.Settings) error { - // AuthSecret has type dockerconfigjson and should be transformed, so it always copied. - if dvcrSettings.AuthSecret != "" { - authSecret := supGen.DVCRAuthSecretForDV() - err := object.CleanupByName(ctx, client, authSecret, &corev1.Secret{}) - if err != nil && !k8serrors.IsNotFound(err) { - return err - } - } - - // CABundle needs transformation, so it always copied. - if dvcrSettings.CertsSecret != "" { - caBundleCM := supGen.DVCRCABundleConfigMapForDV() - err := object.CleanupByName(ctx, client, caBundleCM, &corev1.ConfigMap{}) - if err != nil && !k8serrors.IsNotFound(err) { - return err - } - } - - return nil -} - func EnsureForIngress(ctx context.Context, client client.Client, supGen Generator, ing *netv1.Ingress, dvcrSettings *dvcr.Settings) error { if ShouldCopyUploaderTLSSecret(dvcrSettings, supGen) { tlsSecret := supGen.UploaderTLSSecretForIngress() @@ -204,10 +140,3 @@ func EnsureForIngress(ctx context.Context, client client.Client, supGen Generato } return nil } - -type DataVolumeSupplement interface { - DataVolume() types.NamespacedName - DVCRAuthSecretForDV() types.NamespacedName - DVCRCABundleConfigMapForDV() types.NamespacedName - NetworkPolicy() types.NamespacedName -} diff --git a/images/virtualization-artifact/pkg/controller/supplements/fetch.go b/images/virtualization-artifact/pkg/controller/supplements/fetch.go index b20766538b..9b848aa8ae 100644 --- a/images/virtualization-artifact/pkg/controller/supplements/fetch.go +++ b/images/virtualization-artifact/pkg/controller/supplements/fetch.go @@ -40,8 +40,7 @@ const ( SupplementUploaderIngress SupplementType = "UploaderIngress" // Volumes - SupplementPVC SupplementType = "PersistentVolumeClaim" - SupplementDataVolume SupplementType = "DataVolume" + SupplementPVC SupplementType = "PersistentVolumeClaim" // ConfigMaps/Secrets SupplementDVCRAuthSecret SupplementType = "DVCRAuthSecret" @@ -72,8 +71,6 @@ func GetSupplementName(gen Generator, supplementType SupplementType) (types.Name // Volumes case SupplementPVC: return gen.PersistentVolumeClaim(), nil - case SupplementDataVolume: - return gen.DataVolume(), nil // ConfigMaps/Secrets case SupplementDVCRAuthSecret: @@ -114,8 +111,6 @@ func GetLegacySupplementName(gen Generator, supplementType SupplementType) (type // Volumes case SupplementPVC: return gen.LegacyPersistentVolumeClaim(), nil - case SupplementDataVolume: - return gen.LegacyDataVolume(), nil // ConfigMaps/Secrets case SupplementDVCRAuthSecret: diff --git a/images/virtualization-artifact/pkg/controller/supplements/fetch_test.go b/images/virtualization-artifact/pkg/controller/supplements/fetch_test.go index b5f18eba03..d13120cc3a 100644 --- a/images/virtualization-artifact/pkg/controller/supplements/fetch_test.go +++ b/images/virtualization-artifact/pkg/controller/supplements/fetch_test.go @@ -55,7 +55,7 @@ var _ = Describe("FetchSupplement", func() { It("should fetch the resource successfully", func() { pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "d8v-vi-importer-test-image-12345678-1234-1234-1234-123456789abc", + Name: "d8v-vi-importer-12345678-1234-1234-1234-123456789abc", Namespace: "default", }, Spec: corev1.PodSpec{ @@ -115,7 +115,7 @@ var _ = Describe("FetchSupplement", func() { It("should prefer the new naming", func() { newPod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "d8v-vi-importer-test-image-12345678-1234-1234-1234-123456789abc", + Name: "d8v-vi-importer-12345678-1234-1234-1234-123456789abc", Namespace: "default", Labels: map[string]string{"version": "new"}, }, diff --git a/images/virtualization-artifact/pkg/controller/supplements/generator.go b/images/virtualization-artifact/pkg/controller/supplements/generator.go index 4ebd0e917b..874435361c 100644 --- a/images/virtualization-artifact/pkg/controller/supplements/generator.go +++ b/images/virtualization-artifact/pkg/controller/supplements/generator.go @@ -25,16 +25,20 @@ import ( ) const ( - tplCommon = "d8v-%s-%s-%s" - tplDVCRAuthSecret = "d8v-%s-dvcr-auth-%s-%s" - tplDVCRAuthSecretForDV = "d8v-%s-dvcr-auth-dv-%s-%s" - tplDVCRCABundle = "d8v-%s-dvcr-ca-%s-%s" - tplCABundle = "d8v-%s-ca-%s-%s" - tplImagePullSecret = "d8v-%s-pull-image-%s-%s" - tplImporterPod = "d8v-%s-importer-%s-%s" - tplBounderPod = "d8v-%s-bounder-%s-%s" - tplUploaderPod = "d8v-%s-uploader-%s-%s" - tplUploaderTLSSecret = "d8v-%s-tls-%s-%s" + tplCommon = "d8v-%s-%s-%s" + tplDVCRAuthSecret = "d8v-%s-dvcr-auth-%s-%s" + tplDVCRAuthSecretForDV = "d8v-%s-dvcr-auth-dv-%s-%s" + tplDVCRCABundle = "d8v-%s-dvcr-ca-%s-%s" + tplCABundle = "d8v-%s-ca-%s-%s" + tplImagePullSecret = "d8v-%s-pull-image-%s-%s" + tplImporterPod = "d8v-%s-importer-%s" + tplPVCImporterPod = "d8v-%s-pvc-importer-%s" + tplPVCSourceImporterPod = "d8v-%s-pvc-source-importer-%s" + tplPVCSourceImporterService = "d8v-%s-pvc-source-nbd-%s" + tplPVCTargetImporterPod = "d8v-%s-pvc-target-importer-%s" + tplBounderPod = "d8v-%s-bounder-%s-%s" + tplUploaderPod = "d8v-%s-uploader-%s-%s" + tplUploaderTLSSecret = "d8v-%s-tls-%s-%s" ) type Generator interface { @@ -44,10 +48,13 @@ type Generator interface { BounderPod() types.NamespacedName ImporterPod() types.NamespacedName + PVCImporterPod() types.NamespacedName + PVCSourceImporterPod() types.NamespacedName + PVCSourceImporterService() types.NamespacedName + PVCTargetImporterPod() types.NamespacedName UploaderPod() types.NamespacedName UploaderService() types.NamespacedName UploaderIngress() types.NamespacedName - DataVolume() types.NamespacedName PersistentVolumeClaim() types.NamespacedName CABundleConfigMap() types.NamespacedName DVCRAuthSecret() types.NamespacedName @@ -57,13 +64,14 @@ type Generator interface { ImagePullSecret() types.NamespacedName NetworkPolicy() types.NamespacedName CommonSupplement() types.NamespacedName + CommonResourceName() types.NamespacedName LegacyBounderPod() types.NamespacedName LegacyImporterPod() types.NamespacedName LegacyUploaderPod() types.NamespacedName LegacyUploaderService() types.NamespacedName LegacyUploaderIngress() types.NamespacedName - LegacyDataVolume() types.NamespacedName + LegacyCommonResourceName() types.NamespacedName LegacyPersistentVolumeClaim() types.NamespacedName LegacyCABundleConfigMap() types.NamespacedName LegacyDVCRAuthSecret() types.NamespacedName @@ -116,8 +124,7 @@ func (g *generator) DVCRAuthSecret() types.NamespacedName { return g.generateName(tplDVCRAuthSecret, kvalidation.DNS1123SubdomainMaxLength) } -// DVCRAuthSecretForDV returns name and namespace for auth Secret copy -// compatible with DataVolume: with accessKeyId and secretKey fields. +// DVCRAuthSecretForDV returns name and namespace for CDI-compatible auth Secret copy. func (g *generator) DVCRAuthSecretForDV() types.NamespacedName { return g.generateName(tplDVCRAuthSecretForDV, kvalidation.DNS1123SubdomainMaxLength) } @@ -139,7 +146,47 @@ func (g *generator) ImagePullSecret() types.NamespacedName { // ImporterPod generates name for importer Pod. func (g *generator) ImporterPod() types.NamespacedName { - return g.generateName(tplImporterPod, kvalidation.DNS1123SubdomainMaxLength) + name := fmt.Sprintf(tplImporterPod, g.prefix, g.UID()) + return types.NamespacedName{ + Name: name, + Namespace: g.namespace, + } +} + +// PVCImporterPod generates name for the pvc-importer Pod that imports data +// from DVCR into the target PersistentVolumeClaim. It is intentionally +// distinct from ImporterPod() to avoid colliding with the dvcr-importer Pod +// that runs in the first import phase. +func (g *generator) PVCImporterPod() types.NamespacedName { + name := fmt.Sprintf(tplPVCImporterPod, g.prefix, g.UID()) + return types.NamespacedName{ + Name: name, + Namespace: g.namespace, + } +} + +func (g *generator) PVCSourceImporterPod() types.NamespacedName { + name := fmt.Sprintf(tplPVCSourceImporterPod, g.prefix, g.UID()) + return types.NamespacedName{ + Name: name, + Namespace: g.namespace, + } +} + +func (g *generator) PVCSourceImporterService() types.NamespacedName { + name := fmt.Sprintf(tplPVCSourceImporterService, g.prefix, g.UID()) + return types.NamespacedName{ + Name: name, + Namespace: g.namespace, + } +} + +func (g *generator) PVCTargetImporterPod() types.NamespacedName { + name := fmt.Sprintf(tplPVCTargetImporterPod, g.prefix, g.UID()) + return types.NamespacedName{ + Name: name, + Namespace: g.namespace, + } } // BounderPod generates name for bounder Pod. @@ -167,9 +214,8 @@ func (g *generator) UploaderTLSSecretForIngress() types.NamespacedName { return g.generateName(tplUploaderTLSSecret, kvalidation.DNS1123SubdomainMaxLength) } -// DataVolume generates name for underlying DataVolume. -// DataVolume is always one for vmd/vmi, so prefix is used. -func (g *generator) DataVolume() types.NamespacedName { +// CommonResourceName generates the shared resource name used by older resource layouts. +func (g *generator) CommonResourceName() types.NamespacedName { return g.generateName(tplCommon, kvalidation.DNS1123SubdomainMaxLength) } @@ -204,8 +250,7 @@ func (g *generator) LegacyDVCRAuthSecret() types.NamespacedName { return g.shortenNamespaced(name) } -// LegacyDVCRAuthSecretForDV returns old format name for auth Secret copy -// compatible with DataVolume: with accessKeyId and secretKey fields. +// LegacyDVCRAuthSecretForDV returns old format name for CDI-compatible auth Secret copy. func (g *generator) LegacyDVCRAuthSecretForDV() types.NamespacedName { name := fmt.Sprintf("%s-dvcr-auth-dv-%s", g.prefix, g.name) return g.shortenNamespaced(name) @@ -265,14 +310,13 @@ func (g *generator) LegacyUploaderTLSSecretForIngress() types.NamespacedName { return g.shortenNamespaced(name) } -// LegacyDataVolume generates old format name for underlying DataVolume. -// DataVolume is always one for vmd/vmi, so prefix is used. -func (g *generator) LegacyDataVolume() types.NamespacedName { - dvName := fmt.Sprintf("%s-%s-%s", g.prefix, g.name, string(g.uid)) - return g.shortenNamespaced(dvName) +// LegacyCommonResourceName generates the shared resource name used by older resource layouts. +func (g *generator) LegacyCommonResourceName() types.NamespacedName { + name := fmt.Sprintf("%s-%s-%s", g.prefix, g.name, string(g.uid)) + return g.shortenNamespaced(name) } // LegacyPersistentVolumeClaim generates old format name for underlying PersistentVolumeClaim. func (g *generator) LegacyPersistentVolumeClaim() types.NamespacedName { - return g.LegacyDataVolume() + return g.LegacyCommonResourceName() } diff --git a/images/virtualization-artifact/pkg/controller/supplements/generator_test.go b/images/virtualization-artifact/pkg/controller/supplements/generator_test.go index 6df9f79a5c..43e7f84c08 100644 --- a/images/virtualization-artifact/pkg/controller/supplements/generator_test.go +++ b/images/virtualization-artifact/pkg/controller/supplements/generator_test.go @@ -48,7 +48,9 @@ var _ = Describe("Generator", func() { Expect(result.Name).To(HavePrefix("d8v-")) Expect(result.Name).To(ContainSubstring(expectedPrefix)) - Expect(result.Name).To(ContainSubstring(name)) + if expectedPrefix != "importer" && expectedPrefix != "pvc-importer" && expectedPrefix != "pvc-source-importer" && expectedPrefix != "pvc-source-nbd" && expectedPrefix != "pvc-target-importer" { + Expect(result.Name).To(ContainSubstring(name)) + } Expect(result.Name).To(HaveSuffix(string(uid))) }, Entry("DVCRAuthSecret", func(g Generator) types.NamespacedName { return g.DVCRAuthSecret() }, "dvcr-auth"), @@ -57,12 +59,16 @@ var _ = Describe("Generator", func() { Entry("CABundleConfigMap", func(g Generator) types.NamespacedName { return g.CABundleConfigMap() }, "ca"), Entry("ImagePullSecret", func(g Generator) types.NamespacedName { return g.ImagePullSecret() }, "pull-image"), Entry("ImporterPod", func(g Generator) types.NamespacedName { return g.ImporterPod() }, "importer"), + Entry("PVCImporterPod", func(g Generator) types.NamespacedName { return g.PVCImporterPod() }, "pvc-importer"), + Entry("PVCSourceImporterPod", func(g Generator) types.NamespacedName { return g.PVCSourceImporterPod() }, "pvc-source-importer"), + Entry("PVCSourceImporterService", func(g Generator) types.NamespacedName { return g.PVCSourceImporterService() }, "pvc-source-nbd"), + Entry("PVCTargetImporterPod", func(g Generator) types.NamespacedName { return g.PVCTargetImporterPod() }, "pvc-target-importer"), Entry("BounderPod", func(g Generator) types.NamespacedName { return g.BounderPod() }, "bounder"), Entry("UploaderPod", func(g Generator) types.NamespacedName { return g.UploaderPod() }, "uploader"), Entry("UploaderService", func(g Generator) types.NamespacedName { return g.UploaderService() }, "vi"), Entry("UploaderIngress", func(g Generator) types.NamespacedName { return g.UploaderIngress() }, "vi"), Entry("UploaderTLSSecret", func(g Generator) types.NamespacedName { return g.UploaderTLSSecretForIngress() }, "tls"), - Entry("DataVolume", func(g Generator) types.NamespacedName { return g.DataVolume() }, "vi"), + Entry("CommonResourceName", func(g Generator) types.NamespacedName { return g.CommonResourceName() }, "vi"), Entry("PersistentVolumeClaim", func(g Generator) types.NamespacedName { return g.PersistentVolumeClaim() }, "vi"), Entry("NetworkPolicy", func(g Generator) types.NamespacedName { return g.NetworkPolicy() }, "vi"), Entry("CommonSupplement", func(g Generator) types.NamespacedName { return g.CommonSupplement() }, "vi"), @@ -84,12 +90,13 @@ var _ = Describe("Generator", func() { Entry("CABundleConfigMap - 253 limit", func(g Generator) types.NamespacedName { return g.CABundleConfigMap() }, kvalidation.DNS1123SubdomainMaxLength), Entry("ImagePullSecret - 253 limit", func(g Generator) types.NamespacedName { return g.ImagePullSecret() }, kvalidation.DNS1123SubdomainMaxLength), Entry("ImporterPod - 253 limit", func(g Generator) types.NamespacedName { return g.ImporterPod() }, kvalidation.DNS1123SubdomainMaxLength), + Entry("PVCImporterPod - 253 limit", func(g Generator) types.NamespacedName { return g.PVCImporterPod() }, kvalidation.DNS1123SubdomainMaxLength), Entry("BounderPod - 253 limit", func(g Generator) types.NamespacedName { return g.BounderPod() }, kvalidation.DNS1123SubdomainMaxLength), Entry("UploaderPod - 253 limit", func(g Generator) types.NamespacedName { return g.UploaderPod() }, kvalidation.DNS1123SubdomainMaxLength), Entry("UploaderService - 63 limit", func(g Generator) types.NamespacedName { return g.UploaderService() }, kvalidation.DNS1123LabelMaxLength), Entry("UploaderIngress - 253 limit", func(g Generator) types.NamespacedName { return g.UploaderIngress() }, kvalidation.DNS1123SubdomainMaxLength), Entry("UploaderTLSSecret - 253 limit", func(g Generator) types.NamespacedName { return g.UploaderTLSSecretForIngress() }, kvalidation.DNS1123SubdomainMaxLength), - Entry("DataVolume - 253 limit", func(g Generator) types.NamespacedName { return g.DataVolume() }, kvalidation.DNS1123SubdomainMaxLength), + Entry("CommonResourceName - 253 limit", func(g Generator) types.NamespacedName { return g.CommonResourceName() }, kvalidation.DNS1123SubdomainMaxLength), Entry("PersistentVolumeClaim - 253 limit", func(g Generator) types.NamespacedName { return g.PersistentVolumeClaim() }, kvalidation.DNS1123SubdomainMaxLength), Entry("NetworkPolicy - 253 limit", func(g Generator) types.NamespacedName { return g.NetworkPolicy() }, kvalidation.DNS1123SubdomainMaxLength), Entry("CommonSupplement - 253 limit", func(g Generator) types.NamespacedName { return g.CommonSupplement() }, kvalidation.DNS1123SubdomainMaxLength), diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go b/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go index 5c0f0a01ad..79b71f6ece 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go @@ -25,6 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization-controller/pkg/common" commonvd "github.com/deckhouse/virtualization-controller/pkg/common/vd" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/service" @@ -162,8 +163,52 @@ func (h LifeCycleHandler) Handle(ctx context.Context, vd *v1alpha2.VirtualDisk) result, err := ds.Sync(ctx, vd) if err != nil { + // The namespace is being torn down (e.g. Project/namespace cleanup): the + // VirtualDisk is going away too, so creating helper objects is legitimately + // rejected. Surface it on the Ready condition instead of failing the reconcile. + if common.ErrNamespaceTerminating(err) { + cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message("Namespace is terminating: provisioning is paused.") + conditions.SetCondition(cb, &vd.Status.Conditions) + return reconcile.Result{}, nil + } return reconcile.Result{}, fmt.Errorf("failed to sync virtual disk data source %s: %w", ds.Name(), err) } + normalizeProgress(vd) + return result, nil } + +// normalizeProgress enforces the phase/progress invariants on the final status: +// - a disk that has not yet entered Provisioning ("" or Pending) must NOT +// expose any progress percentage. Progress describes how far the import +// has advanced, so it is meaningful only once the import has actually +// started; any earlier setter (some source steps optimistically populate +// "0%" before they have decided whether the phase will advance to +// Provisioning in this reconcile) is unconditionally cleared here so +// that consumers never observe e.g. "Pending 0%"; +// - a disk in the Provisioning phase must always expose a progress +// percentage; until the importer reports real progress it defaults to +// "0%"; +// - a disk parked in WaitForFirstConsumer is mid-import and merely +// waiting for a VirtualMachine consumer, so the previously reported +// progress is preserved verbatim. WaitForFirstConsumer is a legitimate +// intra-Provisioning pause, not a reset, and clearing it here would +// wipe import progress in flight; +// - a disk parked in WaitForUserUpload has not received any data yet, so +// its progress is always "0%". +func normalizeProgress(vd *v1alpha2.VirtualDisk) { + switch vd.Status.Phase { + case "", v1alpha2.DiskPending: + vd.Status.Progress = "" + case v1alpha2.DiskProvisioning: + if vd.Status.Progress == "" { + vd.Status.Progress = "0%" + } + case v1alpha2.DiskWaitForUserUpload: + vd.Status.Progress = "0%" + } +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go index 08d13e55de..16d90e4ad8 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go @@ -18,6 +18,7 @@ package internal import ( "context" + "errors" "log/slog" . "github.com/onsi/ginkgo/v2" @@ -293,6 +294,108 @@ var _ = Describe("LifeCycleHandler Run", func() { ), ) + DescribeTable( + "Phase/progress invariants on the final status", + func(phase v1alpha2.DiskPhase, syncProgress, expectedProgress string) { + var sourcesMock SourcesMock + sourcesMock.ChangedFunc = func(_ context.Context, _ *v1alpha2.VirtualDisk) bool { + return false + } + sourcesMock.GetFunc = func(_ v1alpha2.DataSourceType) (source.Handler, bool) { + return &source.HandlerMock{SyncFunc: func(_ context.Context, vd *v1alpha2.VirtualDisk) (reconcile.Result, error) { + vd.Status.Phase = phase + vd.Status.Progress = syncProgress + return reconcile.Result{}, nil + }}, true + } + recorder := &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + ctx := logger.ToContext(context.TODO(), testutil.NewNoOpSlogLogger()) + vd := v1alpha2.VirtualDisk{ + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeHTTP, + }, + }, + Status: v1alpha2.VirtualDiskStatus{ + StorageClassName: "vd-sc", + Conditions: []metav1.Condition{ + { + Type: vdcondition.DatasourceReadyType.String(), + Status: metav1.ConditionTrue, + }, + { + Type: vdcondition.StorageClassReadyType.String(), + Status: metav1.ConditionTrue, + }, + }, + }, + } + + handler := NewLifeCycleHandler(recorder, nil, &sourcesMock, nil) + _, err := handler.Handle(ctx, &vd) + Expect(err).NotTo(HaveOccurred()) + Expect(vd.Status.Phase).To(Equal(phase)) + Expect(vd.Status.Progress).To(Equal(expectedProgress)) + }, + Entry("Provisioning defaults empty progress to 0%", v1alpha2.DiskProvisioning, "", "0%"), + Entry("Provisioning keeps the reported progress", v1alpha2.DiskProvisioning, "42.0%", "42.0%"), + Entry("WaitForUserUpload forces empty progress to 0%", v1alpha2.DiskWaitForUserUpload, "", "0%"), + Entry("WaitForUserUpload forces progress to 0%", v1alpha2.DiskWaitForUserUpload, "73%", "0%"), + Entry("Pending clears any progress prematurely set by a source step", v1alpha2.DiskPending, "55%", ""), + Entry("Pending keeps progress empty", v1alpha2.DiskPending, "", ""), + Entry("WaitForFirstConsumer preserves the in-flight progress", v1alpha2.DiskWaitForFirstConsumer, "12%", "12%"), + Entry("WaitForFirstConsumer leaves an empty progress untouched", v1alpha2.DiskWaitForFirstConsumer, "", ""), + Entry("empty phase clears any progress prematurely set by a source step", v1alpha2.DiskPhase(""), "10%", ""), + Entry("Ready keeps the reported progress untouched", v1alpha2.DiskReady, "100%", "100%"), + ) + + It("surfaces a namespace-terminating sync error on the Ready condition without failing the reconcile", func() { + var sourcesMock SourcesMock + sourcesMock.ChangedFunc = func(_ context.Context, _ *v1alpha2.VirtualDisk) bool { + return false + } + sourcesMock.GetFunc = func(_ v1alpha2.DataSourceType) (source.Handler, bool) { + return &source.HandlerMock{SyncFunc: func(_ context.Context, _ *v1alpha2.VirtualDisk) (reconcile.Result, error) { + return reconcile.Result{}, errors.New(`secrets "d8v-vd-dvcr-auth" is forbidden: unable to create new content in namespace ns because it is being terminated`) + }}, true + } + recorder := &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + ctx := logger.ToContext(context.TODO(), testutil.NewNoOpSlogLogger()) + vd := v1alpha2.VirtualDisk{ + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeHTTP, + }, + }, + Status: v1alpha2.VirtualDiskStatus{ + StorageClassName: "vd-sc", + Conditions: []metav1.Condition{ + { + Type: vdcondition.DatasourceReadyType.String(), + Status: metav1.ConditionTrue, + }, + { + Type: vdcondition.StorageClassReadyType.String(), + Status: metav1.ConditionTrue, + }, + }, + }, + } + + handler := NewLifeCycleHandler(recorder, nil, &sourcesMock, nil) + _, err := handler.Handle(ctx, &vd) + Expect(err).NotTo(HaveOccurred()) + + readyCond, ok := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions) + Expect(ok).To(BeTrue()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal(vdcondition.Provisioning.String())) + }) + It("should handle a VirtualDisk without data source", func() { var sourcesMock SourcesMock recorder := &eventrecord.EventRecorderLoggerMock{ diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/blank.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/blank.go index a1e722f7d4..8a7f2a961a 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/blank.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/blank.go @@ -39,13 +39,15 @@ const blankDataSource = "blank" type BlankDataSource struct { diskService BlankDataSourceDiskService + pvcService DataSourcePVCService recorder eventrecord.EventRecorderLogger client client.Client } -func NewBlankDataSource(recorder eventrecord.EventRecorderLogger, diskService BlankDataSourceDiskService, client client.Client) *BlankDataSource { +func NewBlankDataSource(recorder eventrecord.EventRecorderLogger, diskService BlankDataSourceDiskService, pvcService DataSourcePVCService, client client.Client) *BlankDataSource { return &BlankDataSource{ diskService: diskService, + pvcService: pvcService, recorder: recorder, client: client, } @@ -71,7 +73,7 @@ func (ds BlankDataSource) Sync(ctx context.Context, vd *v1alpha2.VirtualDisk) (r return steptaker.NewStepTakers[*v1alpha2.VirtualDisk]( step.NewReadyStep(ds.diskService, pvc, cb), step.NewTerminatingStep(pvc), - step.NewCreateBlankPVCStep(pvc, ds.diskService, ds.client, cb), + step.NewCreateBlankPVCStep(pvc, ds.diskService, ds.pvcService, ds.client, cb), step.NewWaitForPVCStep(pvc, ds.client, cb), ).Run(ctx, vd) } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/blank_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/blank_test.go index be858ba5c5..0a30db4243 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/blank_test.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/blank_test.go @@ -29,11 +29,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -76,9 +76,6 @@ var _ = Describe("Blank", func() { CleanUpSupplementsFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, - ProtectFunc: func(_ context.Context, _ supplements.Generator, _ client.Object, _ *cdiv1.DataVolume, _ *corev1.PersistentVolumeClaim) error { - return nil - }, } sc = &storagev1.StorageClass{ @@ -143,7 +140,8 @@ var _ = Describe("Blank", func() { }, }).Build() - syncer := NewBlankDataSource(recorder, svc, client) + pvcSvc := service.NewPersistentVolumeClaimService(client, nil, nil, service.DiskImporterConfig{}) + syncer := NewBlankDataSource(recorder, svc, pvcSvc, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -159,7 +157,7 @@ var _ = Describe("Blank", func() { It("checks size in spec", func() { client := fake.NewClientBuilder().WithScheme(scheme).Build() - syncer := NewBlankDataSource(nil, nil, client) + syncer := NewBlankDataSource(nil, nil, nil, client) res, err := syncer.Sync(ctx, vd) Expect(err).To(HaveOccurred()) @@ -168,7 +166,7 @@ var _ = Describe("Blank", func() { It("checks storage class is set in status", func() { client := fake.NewClientBuilder().WithScheme(scheme).Build() - syncer := NewBlankDataSource(nil, nil, client) + syncer := NewBlankDataSource(nil, nil, nil, client) res, err := syncer.Sync(ctx, vd) Expect(err).To(HaveOccurred()) @@ -182,7 +180,7 @@ var _ = Describe("Blank", func() { sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingWaitForFirstConsumer) client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() - syncer := NewBlankDataSource(nil, nil, client) + syncer := NewBlankDataSource(nil, nil, nil, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -197,7 +195,7 @@ var _ = Describe("Blank", func() { sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingImmediate) client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() - syncer := NewBlankDataSource(nil, nil, client) + syncer := NewBlankDataSource(nil, nil, nil, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -213,7 +211,7 @@ var _ = Describe("Blank", func() { pvc.Status.Phase = corev1.ClaimBound client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() - syncer := NewBlankDataSource(nil, svc, client) + syncer := NewBlankDataSource(nil, svc, nil, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -241,7 +239,7 @@ var _ = Describe("Blank", func() { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects().Build() - syncer := NewBlankDataSource(nil, svc, client) + syncer := NewBlankDataSource(nil, svc, nil, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -257,7 +255,7 @@ var _ = Describe("Blank", func() { vd.Status.Target.PersistentVolumeClaim = pvc.Name client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() - syncer := NewBlankDataSource(nil, svc, client) + syncer := NewBlankDataSource(nil, svc, nil, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/http.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/http.go index ba2f910fac..c707ccd5fa 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/http.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/http.go @@ -18,29 +18,17 @@ package source import ( "context" - "errors" "fmt" - "time" - corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/virtualization-controller/pkg/common" - "github.com/deckhouse/virtualization-controller/pkg/common/datasource" - "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" - "github.com/deckhouse/virtualization-controller/pkg/common/object" - podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" - "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/common/steptaker" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/importer" "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source/step" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" "github.com/deckhouse/virtualization-controller/pkg/dvcr" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -52,9 +40,10 @@ import ( const httpDataSource = "http" type HTTPDataSource struct { - statService *service.StatService - importerService *service.ImporterService - diskService *service.DiskService + statService HTTPDataSourceStatService + importerService HTTPDataSourceImporterService + diskService HTTPDataSourceDiskService + pvcService DataSourcePVCService dvcrSettings *dvcr.Settings client client.Client recorder eventrecord.EventRecorderLogger @@ -62,9 +51,10 @@ type HTTPDataSource struct { func NewHTTPDataSource( recorder eventrecord.EventRecorderLogger, - statService *service.StatService, - importerService *service.ImporterService, - diskService *service.DiskService, + statService HTTPDataSourceStatService, + importerService HTTPDataSourceImporterService, + diskService HTTPDataSourceDiskService, + pvcService DataSourcePVCService, dvcrSettings *dvcr.Settings, client client.Client, ) *HTTPDataSource { @@ -72,6 +62,7 @@ func NewHTTPDataSource( statService: statService, importerService: importerService, diskService: diskService, + pvcService: pvcService, dvcrSettings: dvcrSettings, client: client, recorder: recorder, @@ -81,251 +72,33 @@ func NewHTTPDataSource( func (ds HTTPDataSource) Sync(ctx context.Context, vd *v1alpha2.VirtualDisk) (reconcile.Result, error) { log, ctx := logger.GetDataSourceContext(ctx, httpDataSource) - condition, _ := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions) + supgen := vdsupplements.NewGenerator(vd) + cb := conditions.NewConditionBuilder(vdcondition.ReadyType).Generation(vd.Generation) defer func() { conditions.SetCondition(cb, &vd.Status.Conditions) }() - supgen := vdsupplements.NewGenerator(vd) pod, err := ds.importerService.GetPod(ctx, supgen) if err != nil { - return reconcile.Result{}, err - } - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) - if err != nil { - return reconcile.Result{}, err + return reconcile.Result{}, fmt.Errorf("fetch importer pod: %w", err) } - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) - vdsupplements.SetPVCName(vd, dv.Status.ClaimName) - } - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vd.Status.StorageClassName) + pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { - return reconcile.Result{}, err + return reconcile.Result{}, fmt.Errorf("fetch pvc: %w", err) } - - switch { - case IsDiskProvisioningFinished(condition): - log.Debug("Disk provisioning finished: clean up") - - setPhaseConditionForFinishedDisk(vd, pvc, cb, &vd.Status.Phase, supgen) - - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen, vd, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - - // Unprotect import time supplements to delete them later. - err = ds.importerService.Unprotect(ctx, pod, supgen) - if err != nil { - return reconcile.Result{}, err - } - - err = ds.diskService.Unprotect(ctx, supgen, dv) - if err != nil { - return reconcile.Result{}, err - } - - return CleanUpSupplements(ctx, vd, ds) - case object.AnyTerminating(pod, dv, pvc): - log.Info("Waiting for supplements to be terminated") - case pod == nil: - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncStarted, - "The HTTP DataSource import to DVCR has started", - ) - - vd.Status.Progress = "0%" - - envSettings := ds.getEnvSettings(vd, supgen) - - err = ds.importerService.Start( - ctx, envSettings, vd, supgen, - datasource.NewCABundleForVMD(vd.GetNamespace(), vd.Spec.DataSource), - service.WithSystemNodeToleration(), - ) - switch { - case err == nil: - // OK. - case common.ErrQuotaExceeded(err): - ds.recorder.Event(vd, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceQuotaExceeded, "DataSource quota exceed") - return setQuotaExceededPhaseCondition(vd, cb, &vd.Status.Phase, err, vd.CreationTimestamp), nil - default: - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return reconcile.Result{}, err - } - - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "DVCR Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case !podutil.IsPodComplete(pod): - log.Info("Provisioning to DVCR is in progress", "podPhase", pod.Status.Phase) - - err = ds.statService.CheckPod(pod) - if err != nil { - return reconcile.Result{}, setPhaseConditionFromPodError(ctx, err, pod, vd, cb, ds.client) - } - - err = ds.importerService.Protect(ctx, pod, supgen) - if err != nil { - return reconcile.Result{}, err - } - - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "Import is in the process of provisioning to DVCR.") - - vd.Status.Progress = ds.statService.GetProgress(vd.GetUID(), pod, vd.Status.Progress, service.NewScaleOption(0, 50)) - vd.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vd.GetUID(), pod) - case dv == nil: - if isStorageClassWFFC(sc) && len(vd.Status.AttachedToVirtualMachines) != 1 { - vd.Status.Progress = "50%" - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - setReadyConditionWithWFFCAccounting( - vd, - cb, - metav1.ConditionFalse, - vdcondition.WaitingForFirstConsumer, - "The provisioning has been suspended: a created and scheduled virtual machine is awaited.", - ) - return reconcile.Result{}, nil - } - - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncStarted, - "The HTTP DataSource import to PVC has started", - ) - - err = ds.statService.CheckPod(pod) - if err != nil { - vd.Status.Phase = v1alpha2.DiskFailed - - switch { - case errors.Is(err, service.ErrProvisioningFailed): - ds.recorder.Event(vd, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceDiskProvisioningFailed, "Disk provisioning failed") - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningFailed, service.CapitalizeFirstLetter(err.Error()+".")) - return reconcile.Result{}, nil - default: - return reconcile.Result{}, err - } - } - - vd.Status.Progress = "50%" - vd.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vd.GetUID(), pod) - - if imageformat.IsISO(ds.statService.GetFormat(pod)) { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, ErrISOSourceNotSupported) - return reconcile.Result{}, nil - } - - var diskSize resource.Quantity - diskSize, err = ds.getPVCSize(vd, pod) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, err) - - if errors.Is(err, service.ErrInsufficientPVCSize) { - return reconcile.Result{}, nil - } - - return reconcile.Result{}, err - } - - source := ds.getSource(supgen, ds.statService.GetDVCRImageName(pod)) - - var nodePlacement *provisioner.NodePlacement - nodePlacement, err = getNodePlacement(ctx, ds.client, vd) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return reconcile.Result{}, fmt.Errorf("failed to get importer tolerations: %w", err) - } - - err = ds.diskService.Start(ctx, diskSize, sc, source, vd, supgen, service.WithNodePlacement(nodePlacement)) - if updated, err := setPhaseConditionFromStorageError(err, vd, cb); err != nil || updated { - return reconcile.Result{}, err - } - - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "PVC Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vd.Status.Phase = v1alpha2.DiskPending - if dv.Status.ClaimName != "" && isStorageClassWFFC(sc) { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - } - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.QuotaExceeded, dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vd.Status.Phase = v1alpha2.DiskPending - if dv.Status.ClaimName != "" && isStorageClassWFFC(sc) { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - } - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ImagePullFailed, dvRunningCondition.Message) - ds.recorder.Event(vd, corev1.EventTypeWarning, vdcondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncCompleted, - "The HTTP DataSource import has completed", - ) - - vd.Status.Phase = v1alpha2.DiskReady - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionTrue, vdcondition.Ready, "") - - vd.Status.Progress = "100%" - vd.Status.Capacity = ds.diskService.GetCapacity(pvc) - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - err = ds.diskService.CheckProvisioning(ctx, pvc) - if err != nil { - return reconcile.Result{}, setPhaseConditionFromProvisioningError(ctx, err, cb, vd, dv, ds.diskService, ds.client) - } - - vd.Status.Progress = ds.diskService.GetProgress(dv, vd.Status.Progress, service.NewScaleOption(50, 100)) - vd.Status.Capacity = ds.diskService.GetCapacity(pvc) - - err = ds.diskService.Protect(ctx, supgen, vd, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, ptr.Deref(pvc.Spec.StorageClassName, "")) - if err != nil { - return reconcile.Result{}, err - } - - if err = setPhaseConditionForPVCProvisioningDisk(ctx, dv, vd, pvc, sc, cb, ds.diskService); err != nil { - return reconcile.Result{}, err - } - - return reconcile.Result{}, nil + if pvc != nil { + ctx = logger.ToContext(ctx, log.With("pvc.name", pvc.Name, "pvc.status.phase", pvc.Status.Phase)) } - return reconcile.Result{RequeueAfter: time.Second}, nil + return steptaker.NewStepTakers[*v1alpha2.VirtualDisk]( + step.NewCleanUpImporterStep(pod, ds.importerService), + step.NewReadyStep(ds.diskService, pvc, cb), + step.NewTerminatingStep(pvc), + step.NewCreateImporterStep(pvc, pod, ds.buildEnvSettings, ds.importerService, ds.recorder, cb, "The HTTP DataSource import to DVCR has started"), + step.NewWaitForDVCRImporterStep(pod, ds.statService, ds.importerService, ds.client, cb), + step.NewCreatePVCFromDVCRStep(pvc, pod, ds.statService, ds.diskService, ds.pvcService, ds.client, cb), + step.NewWaitForPVCImportStep(pvc, step.DVCRPodPVCImportSource(pod, ds.statService), ds.pvcService, ds.statService, service.NewScaleOption(50, 100), ds.client, cb), + ).Run(ctx, vd) } func (ds HTTPDataSource) CleanUp(ctx context.Context, vd *v1alpha2.VirtualDisk) (bool, error) { @@ -333,12 +106,12 @@ func (ds HTTPDataSource) CleanUp(ctx context.Context, vd *v1alpha2.VirtualDisk) importerRequeue, err := ds.importerService.CleanUp(ctx, supgen) if err != nil { - return false, err + return false, fmt.Errorf("clean up importer: %w", err) } diskRequeue, err := ds.diskService.CleanUp(ctx, supgen) if err != nil { - return false, err + return false, fmt.Errorf("clean up disk: %w", err) } return importerRequeue || diskRequeue, nil @@ -348,31 +121,11 @@ func (ds HTTPDataSource) Validate(_ context.Context, _ *v1alpha2.VirtualDisk) er return nil } -func (ds HTTPDataSource) CleanUpSupplements(ctx context.Context, vd *v1alpha2.VirtualDisk) (reconcile.Result, error) { - supgen := vdsupplements.NewGenerator(vd) - - importerRequeue, err := ds.importerService.CleanUpSupplements(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - - diskRequeue, err := ds.diskService.CleanUpSupplements(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - - if importerRequeue || diskRequeue { - return reconcile.Result{RequeueAfter: time.Second}, nil - } else { - return reconcile.Result{}, nil - } -} - func (ds HTTPDataSource) Name() string { return httpDataSource } -func (ds HTTPDataSource) getEnvSettings(vd *v1alpha2.VirtualDisk, supgen supplements.Generator) *importer.Settings { +func (ds HTTPDataSource) buildEnvSettings(vd *v1alpha2.VirtualDisk, supgen supplements.Generator) *importer.Settings { var settings importer.Settings importer.ApplyHTTPSourceSettings(&settings, vd.Spec.DataSource.HTTP, supgen) @@ -385,30 +138,3 @@ func (ds HTTPDataSource) getEnvSettings(vd *v1alpha2.VirtualDisk, supgen supplem return &settings } - -func (ds HTTPDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *cdiv1.DataVolumeSource { - // The image was preloaded from source into dvcr. - // We can't use the same data source a second time, but we can set dvcr as the data source. - // Use DV name for the Secret with DVCR auth and the ConfigMap with DVCR CA Bundle. - url := common.DockerRegistrySchemePrefix + dvcrSourceImageName - secretName := sup.DVCRAuthSecretForDV().Name - certConfigMapName := sup.DVCRCABundleConfigMapForDV().Name - - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - } -} - -func (ds HTTPDataSource) getPVCSize(vd *v1alpha2.VirtualDisk, pod *corev1.Pod) (resource.Quantity, error) { - // Get size from the importer Pod to detect if specified PVC size is enough. - unpackedSize, err := resource.ParseQuantity(ds.statService.GetSize(pod).UnpackedBytes) - if err != nil { - return resource.Quantity{}, fmt.Errorf("failed to parse unpacked bytes %s: %w", ds.statService.GetSize(pod).UnpackedBytes, err) - } - - return service.GetValidatedPVCSize(vd.Spec.PersistentVolumeClaim.Size, unpackedSize) -} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/http_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/http_test.go new file mode 100644 index 0000000000..ed6859740c --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/http_test.go @@ -0,0 +1,474 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "errors" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/datasource" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/controller/importer" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +var _ = Describe("HTTPDataSource", func() { + var ( + ctx context.Context + scheme *runtime.Scheme + vd *v1alpha2.VirtualDisk + sc *storagev1.StorageClass + pvc *corev1.PersistentVolumeClaim + disk *HTTPDataSourceDiskServiceMock + pvcSvc *DataSourcePVCServiceMock + importerSvc *HTTPDataSourceImporterServiceMock + stat *HTTPDataSourceStatServiceMock + recorder eventrecord.EventRecorderLogger + dvcrSettings *dvcr.Settings + ) + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + + scheme = runtime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(storagev1.AddToScheme(scheme)).To(Succeed()) + + recorder = &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + + dvcrSettings = &dvcr.Settings{ + RegistryURL: "dvcr.example.com", + } + + sc = &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "sc"}, + } + + vd = &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vd", + Generation: 1, + UID: "22222222-2222-2222-2222-222222222222", + }, + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeHTTP, + HTTP: &v1alpha2.DataSourceHTTP{URL: "https://example.com/image.qcow2"}, + }, + }, + Status: v1alpha2.VirtualDiskStatus{ + StorageClassName: sc.Name, + Target: v1alpha2.DiskTarget{PersistentVolumeClaim: "test-pvc"}, + }, + } + + supgen := vdsupplements.NewGenerator(vd) + pvc = &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: supgen.PersistentVolumeClaim().Name, + Namespace: vd.Namespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr.To(sc.Name)}, + Status: corev1.PersistentVolumeClaimStatus{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}, + }, + } + + disk = &HTTPDataSourceDiskServiceMock{ + GetCapacityFunc: func(_ *corev1.PersistentVolumeClaim) string { return "1Gi" }, + GetPersistentVolumeClaimFunc: func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return pvc, nil + }, + CleanUpFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + CleanUpSupplementsFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + GetVolumeAndAccessModesFunc: func(_ context.Context, _ client.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil + }, + } + + pvcSvc = &DataSourcePVCServiceMock{ + FinalizersFunc: func() []string { return nil }, + ImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { + return nil + }, + WaitForImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + return corev1.PodRunning, nil + }, + } + + importerSvc = &HTTPDataSourceImporterServiceMock{ + GetPodFunc: func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return nil, nil }, + CleanUpFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + ProtectFunc: func(_ context.Context, _ *corev1.Pod, _ supplements.Generator) error { return nil }, + } + + stat = &HTTPDataSourceStatServiceMock{ + GetDVCRImageNameFunc: func(_ *corev1.Pod) string { return "dvcr.example.com/cvi/vd:1" }, + GetSizeFunc: func(_ *corev1.Pod) v1alpha2.ImageStatusSize { + return v1alpha2.ImageStatusSize{UnpackedBytes: "500Mi"} + }, + GetFormatFunc: func(_ *corev1.Pod) string { return "qcow2" }, + GetDownloadSpeedFunc: func(_ types.UID, _ *corev1.Pod) *v1alpha2.StatusSpeed { return nil }, + GetProgressFunc: func(_ types.UID, _ *corev1.Pod, prev string, _ ...service.GetProgressOption) string { + if prev == "" { + return "10%" + } + return prev + }, + CheckPodFunc: func(_ *corev1.Pod) error { return nil }, + } + }) + + newSyncer := func(c client.Client) *HTTPDataSource { + return NewHTTPDataSource(recorder, stat, importerSvc, disk, pvcSvc, dvcrSettings, c) + } + + Context("VirtualDisk has just been created (no importer pod yet)", func() { + It("starts the importer pod and sets DiskProvisioning", func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + var started bool + var gotSettings *importer.Settings + importerSvc.StartFunc = func(_ context.Context, settings *importer.Settings, _ client.Object, _ supplements.Generator, _ *datasource.CABundle, _ ...service.Option) error { + started = true + gotSettings = settings + return nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(started).To(BeTrue()) + Expect(gotSettings).ToNot(BeNil()) + + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + Expect(vd.Status.Progress).To(Equal("0%")) + }) + + It("propagates QuotaExceeded as DiskFailed/QuotaExceeded", func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + importerSvc.StartFunc = func(_ context.Context, _ *importer.Settings, _ client.Object, _ supplements.Generator, _ *datasource.CABundle, _ ...service.Option) error { + return errors.New("exceeded quota: cpu requested but limit reached") + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskFailed)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.QuotaExceeded, true) + }) + }) + + Context("Importer pod is running", func() { + var pod *corev1.Pod + + BeforeEach(func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + pod = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "importer", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + }) + + It("marks Provisioning and updates progress/download speed", func() { + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + Expect(vd.Status.Progress).ToNot(BeEmpty()) + Expect(importerSvc.ProtectCalls()).To(HaveLen(1)) + }) + + It("surfaces ProvisioningFailed when CheckPod returns ErrProvisioningFailed", func() { + stat.CheckPodFunc = func(_ *corev1.Pod) error { return service.ErrProvisioningFailed } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskFailed)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.ProvisioningFailed, true) + }) + }) + + Context("Importer pod completed, no PVC yet", func() { + var pod *corev1.Pod + + BeforeEach(func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + pod = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "importer", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + }) + + It("kicks off the PVC import", func() { + var started bool + pvcSvc.CreateTargetFromDVCRFunc = func(_ context.Context, _ types.NamespacedName, _ string, _ *resource.Quantity, _ client.Object, source *service.PVCImportSourceRegistry, _ service.VolumeAndAccessModesGetter, _ *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + started = true + Expect(source).ToNot(BeNil()) + return corev1.PersistentVolumeClaim{}, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(started).To(BeTrue()) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + }) + + It("fails the disk when the source is ISO", func() { + stat.GetFormatFunc = func(_ *corev1.Pod) string { return "iso" } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskFailed)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.ProvisioningFailed, true) + }) + }) + + Context("PVC is created but not yet Bound", func() { + var pod *corev1.Pod + + BeforeEach(func() { + pod = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "importer", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + }) + + // With the prime/rebind import flow the importer fills a separate prime PVC + // and the target PVC only becomes Bound at the very end (via rebind), so a + // Pending target means the import is in progress, regardless of binding mode. + It("reports Provisioning for WFFC storage class", func() { + pvc.Status.Phase = corev1.ClaimPending + sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingWaitForFirstConsumer) + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + + It("reports Provisioning for Immediate storage class", func() { + pvc.Status.Phase = corev1.ClaimPending + sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingImmediate) + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + }) + + Context("PVC is Bound and the import is still in flight", func() { + BeforeEach(func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationStrategy: service.PopulationStrategyDVCR} + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: vdsupplements.NewGenerator(vd).PVCImporterPod().Name, Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + }) + + It("drives EnsurePVCImport with a registry source built from the importer pod", func() { + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + Expect(source).ToNot(BeNil()) + Expect(source.Registry).ToNot(BeNil()) + return corev1.PodRunning, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + // The step requeues every 2s to refresh vd.Status.Progress from + // the pvc-importer pod metrics while the import is in flight. + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + + It("keeps progress below 100 until PVC population is done", func() { + stat.GetProgressFunc = func(_ types.UID, _ *corev1.Pod, _ string, _ ...service.GetProgressOption) string { + return "100%" + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: vdsupplements.NewGenerator(vd).PVCImporterPod().Name, Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, pod).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + Expect(vd.Status.Progress).To(Equal("99.9%")) + }) + + It("waits for populator when the target PVC already exists", func() { + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationStrategy: service.PopulationStrategyDVCR} + var imported bool + pvcSvc.ImportFunc = func(_ context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { + imported = true + Expect(target.Name).To(Equal(pvc.Name)) + Expect(source).ToNot(BeNil()) + Expect(source.Registry).ToNot(BeNil()) + return nil + } + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + Expect(imported).To(BeTrue()) + Expect(source).ToNot(BeNil()) + Expect(source.Registry).ToNot(BeNil()) + return corev1.PodPending, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(imported).To(BeFalse()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + + It("requeues when EnsurePVCImport reports Succeeded", func() { + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + return corev1.PodSucceeded, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + }) + }) + + Context("PVC is Bound and the import is complete", func() { + BeforeEach(func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationDone: "true"} + }) + + It("marks DiskReady and cleans up the importer once the condition is finished", func() { + vd.Status.Conditions = []metav1.Condition{{ + Type: vdcondition.ReadyType.String(), + Reason: vdcondition.Ready.String(), + Status: metav1.ConditionTrue, + }} + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "importer", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + var cleaned bool + importerSvc.CleanUpFunc = func(_ context.Context, _ supplements.Generator) (bool, error) { + cleaned = true + return true, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(cleaned).To(BeTrue()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskReady)) + ExpectCondition(vd, metav1.ConditionTrue, vdcondition.Ready, false) + ExpectStats(vd) + }) + }) + + Context("CleanUp", func() { + It("delegates to both importer and disk services", func() { + var importerCleaned, diskCleaned bool + importerSvc.CleanUpFunc = func(_ context.Context, _ supplements.Generator) (bool, error) { + importerCleaned = true + return true, nil + } + disk.CleanUpFunc = func(_ context.Context, _ supplements.Generator) (bool, error) { + diskCleaned = true + return false, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + requeue, err := newSyncer(cl).CleanUp(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(requeue).To(BeTrue()) + Expect(importerCleaned).To(BeTrue()) + Expect(diskCleaned).To(BeTrue()) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/interfaces.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/interfaces.go index cd26fd373e..d0fc2976bd 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/interfaces.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/interfaces.go @@ -19,6 +19,8 @@ package source import ( "context" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" @@ -26,7 +28,7 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) -//go:generate go tool moq -rm -out mock.go . Handler BlankDataSourceDiskService ObjectRefVirtualImageDiskService ObjectRefClusterVirtualImageDiskService ObjectRefVirtualDiskSnapshotDiskService +//go:generate go tool moq -rm -out mock.go . Handler BlankDataSourceDiskService DataSourcePVCService ObjectRefVirtualImageDiskService ObjectRefVirtualImageStatService ObjectRefClusterVirtualImageDiskService ObjectRefClusterVirtualImageStatService ObjectRefVirtualDiskSnapshotDiskService UploadDataSourceDiskService UploadDataSourceUploaderService UploadDataSourceStatService HTTPDataSourceDiskService HTTPDataSourceImporterService HTTPDataSourceStatService RegistryDataSourceDiskService RegistryDataSourceImporterService RegistryDataSourceStatService type Handler interface { Name() string @@ -35,6 +37,16 @@ type Handler interface { Validate(ctx context.Context, vd *v1alpha2.VirtualDisk) error } +// DataSourcePVCService is the contract every VD data source uses to populate +// the target PersistentVolumeClaim. It is implemented by +// *service.PersistentVolumeClaimService and is passed to data sources +// alongside DiskService so steps that build a target PVC descriptor can hand +// it off to Import. +type DataSourcePVCService interface { + step.PVCService + step.BlankPVCService +} + type BlankDataSourceDiskService interface { step.VolumeAndAccessModesGetter step.ReadyStepDiskService @@ -44,18 +56,92 @@ type BlankDataSourceDiskService interface { type ObjectRefVirtualImageDiskService interface { step.ReadyStepDiskService - step.WaitForDVStepDiskService - step.CreateDataVolumeStepDiskService - step.EnsureNodePlacementStepDiskService + step.PVCImportStepDiskService +} + +type ObjectRefVirtualImageStatService interface { + step.WaitForPVCImportStepStatService } type ObjectRefClusterVirtualImageDiskService interface { step.ReadyStepDiskService - step.WaitForDVStepDiskService - step.CreateDataVolumeStepDiskService - step.EnsureNodePlacementStepDiskService + step.PVCImportStepDiskService +} + +type ObjectRefClusterVirtualImageStatService interface { + step.WaitForPVCImportStepStatService } type ObjectRefVirtualDiskSnapshotDiskService interface { step.ReadyStepDiskService + step.PVCImportStepDiskService +} + +type UploadDataSourceDiskService interface { + step.ReadyStepDiskService + step.PVCImportStepDiskService + + GetPersistentVolumeClaim(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) + CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) +} + +type UploadDataSourceUploaderService interface { + step.CreateUploaderStepUploaderService + step.WaitForUserUploadStepUploaderService + step.CleanUpUploaderStepUploaderService + + GetPod(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) + GetService(ctx context.Context, sup supplements.Generator) (*corev1.Service, error) + GetIngress(ctx context.Context, sup supplements.Generator) (*netv1.Ingress, error) +} + +type UploadDataSourceStatService interface { + step.WaitForUserUploadStepStatService + step.WaitForDVCRUploaderStepStatService + step.StartImportFromDVCRStepStatService + step.WaitForPVCImportStepStatService +} + +type HTTPDataSourceDiskService interface { + step.ReadyStepDiskService + step.PVCImportStepDiskService + + GetPersistentVolumeClaim(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) + CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) +} + +type HTTPDataSourceImporterService interface { + step.CreateImporterStepImporterService + step.WaitForDVCRImporterStepImporterService + step.CleanUpImporterStepImporterService + + GetPod(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) +} + +type HTTPDataSourceStatService interface { + step.WaitForDVCRImporterStepStatService + step.StartImportFromDVCRStepStatService + step.WaitForPVCImportStepStatService +} + +type RegistryDataSourceDiskService interface { + step.ReadyStepDiskService + step.PVCImportStepDiskService + + GetPersistentVolumeClaim(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) + CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) +} + +type RegistryDataSourceImporterService interface { + step.CreateImporterStepImporterService + step.WaitForDVCRImporterStepImporterService + step.CleanUpImporterStepImporterService + + GetPod(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) +} + +type RegistryDataSourceStatService interface { + step.WaitForDVCRImporterStepStatService + step.StartImportFromDVCRStepStatService + step.WaitForPVCImportStepStatService } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/mock.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/mock.go index 7e1e92e2e9..1ad8cd2657 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/mock.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/mock.go @@ -5,13 +5,20 @@ package source import ( "context" + "github.com/deckhouse/virtualization-controller/pkg/common/datasource" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/controller/importer" "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/controller/uploader" + "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source/step" "github.com/deckhouse/virtualization/api/core/v1alpha2" + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" + netv1 "k8s.io/api/networking/v1" + "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "sync" @@ -245,12 +252,9 @@ var _ BlankDataSourceDiskService = &BlankDataSourceDiskServiceMock{} // GetCapacityFunc: func(pvc *corev1.PersistentVolumeClaim) string { // panic("mock out the GetCapacity method") // }, -// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { // panic("mock out the GetVolumeAndAccessModes method") // }, -// ProtectFunc: func(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { -// panic("mock out the Protect method") -// }, // } // // // use mockedBlankDataSourceDiskService in code that requires BlankDataSourceDiskService @@ -268,10 +272,7 @@ type BlankDataSourceDiskServiceMock struct { GetCapacityFunc func(pvc *corev1.PersistentVolumeClaim) string // GetVolumeAndAccessModesFunc mocks the GetVolumeAndAccessModes method. - GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) - - // ProtectFunc mocks the Protect method. - ProtectFunc func(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error + GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) // calls tracks calls to the methods. calls struct { @@ -301,27 +302,13 @@ type BlankDataSourceDiskServiceMock struct { // Obj is the obj argument value. Obj client.Object // Sc is the sc argument value. - Sc *storagev1.StorageClass - } - // Protect holds details about calls to the Protect method. - Protect []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Sup is the sup argument value. - Sup supplements.Generator - // Owner is the owner argument value. - Owner client.Object - // Dv is the dv argument value. - Dv *cdiv1.DataVolume - // Pvc is the pvc argument value. - Pvc *corev1.PersistentVolumeClaim + Sc *v1.StorageClass } } lockCleanUp sync.RWMutex lockCleanUpSupplements sync.RWMutex lockGetCapacity sync.RWMutex lockGetVolumeAndAccessModes sync.RWMutex - lockProtect sync.RWMutex } // CleanUp calls CleanUpFunc. @@ -429,14 +416,14 @@ func (mock *BlankDataSourceDiskServiceMock) GetCapacityCalls() []struct { } // GetVolumeAndAccessModes calls GetVolumeAndAccessModesFunc. -func (mock *BlankDataSourceDiskServiceMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +func (mock *BlankDataSourceDiskServiceMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { if mock.GetVolumeAndAccessModesFunc == nil { panic("BlankDataSourceDiskServiceMock.GetVolumeAndAccessModesFunc: method is nil but BlankDataSourceDiskService.GetVolumeAndAccessModes was just called") } callInfo := struct { Ctx context.Context Obj client.Object - Sc *storagev1.StorageClass + Sc *v1.StorageClass }{ Ctx: ctx, Obj: obj, @@ -455,12 +442,12 @@ func (mock *BlankDataSourceDiskServiceMock) GetVolumeAndAccessModes(ctx context. func (mock *BlankDataSourceDiskServiceMock) GetVolumeAndAccessModesCalls() []struct { Ctx context.Context Obj client.Object - Sc *storagev1.StorageClass + Sc *v1.StorageClass } { var calls []struct { Ctx context.Context Obj client.Object - Sc *storagev1.StorageClass + Sc *v1.StorageClass } mock.lockGetVolumeAndAccessModes.RLock() calls = mock.calls.GetVolumeAndAccessModes @@ -468,549 +455,588 @@ func (mock *BlankDataSourceDiskServiceMock) GetVolumeAndAccessModesCalls() []str return calls } -// Protect calls ProtectFunc. -func (mock *BlankDataSourceDiskServiceMock) Protect(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { - if mock.ProtectFunc == nil { - panic("BlankDataSourceDiskServiceMock.ProtectFunc: method is nil but BlankDataSourceDiskService.Protect was just called") - } - callInfo := struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim - }{ - Ctx: ctx, - Sup: sup, - Owner: owner, - Dv: dv, - Pvc: pvc, - } - mock.lockProtect.Lock() - mock.calls.Protect = append(mock.calls.Protect, callInfo) - mock.lockProtect.Unlock() - return mock.ProtectFunc(ctx, sup, owner, dv, pvc) -} - -// ProtectCalls gets all the calls that were made to Protect. -// Check the length with: -// -// len(mockedBlankDataSourceDiskService.ProtectCalls()) -func (mock *BlankDataSourceDiskServiceMock) ProtectCalls() []struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim -} { - var calls []struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim - } - mock.lockProtect.RLock() - calls = mock.calls.Protect - mock.lockProtect.RUnlock() - return calls -} - -// Ensure, that ObjectRefVirtualImageDiskServiceMock does implement ObjectRefVirtualImageDiskService. +// Ensure, that DataSourcePVCServiceMock does implement DataSourcePVCService. // If this is not the case, regenerate this file with moq. -var _ ObjectRefVirtualImageDiskService = &ObjectRefVirtualImageDiskServiceMock{} +var _ DataSourcePVCService = &DataSourcePVCServiceMock{} -// ObjectRefVirtualImageDiskServiceMock is a mock implementation of ObjectRefVirtualImageDiskService. +// DataSourcePVCServiceMock is a mock implementation of DataSourcePVCService. // -// func TestSomethingThatUsesObjectRefVirtualImageDiskService(t *testing.T) { +// func TestSomethingThatUsesDataSourcePVCService(t *testing.T) { // -// // make and configure a mocked ObjectRefVirtualImageDiskService -// mockedObjectRefVirtualImageDiskService := &ObjectRefVirtualImageDiskServiceMock{ -// CheckProvisioningFunc: func(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { -// panic("mock out the CheckProvisioning method") +// // make and configure a mocked DataSourcePVCService +// mockedDataSourcePVCService := &DataSourcePVCServiceMock{ +// CreateBlankTargetFunc: func(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, modeGetter step.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { +// panic("mock out the CreateBlankTarget method") // }, -// CleanUpFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { -// panic("mock out the CleanUp method") +// CreateTargetFromDVCRFunc: func(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *service.PVCImportSourceRegistry, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { +// panic("mock out the CreateTargetFromDVCR method") // }, -// CleanUpSupplementsFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { -// panic("mock out the CleanUpSupplements method") +// CreateTargetFromPVCFunc: func(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *corev1.PersistentVolumeClaim, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { +// panic("mock out the CreateTargetFromPVC method") // }, -// GetCapacityFunc: func(pvc *corev1.PersistentVolumeClaim) string { -// panic("mock out the GetCapacity method") +// CreateTargetFromVSFunc: func(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { +// panic("mock out the CreateTargetFromVS method") // }, -// GetProgressFunc: func(dv *cdiv1.DataVolume, prevProgress string, opts ...service.GetProgressOption) string { -// panic("mock out the GetProgress method") +// FinalizersFunc: func() []string { +// panic("mock out the Finalizers method") // }, -// ProtectFunc: func(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { -// panic("mock out the Protect method") +// ImportFunc: func(ctx context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) error { +// panic("mock out the Import method") // }, -// StartFunc: func(ctx context.Context, pvcSize resource.Quantity, sc *storagev1.StorageClass, source *cdiv1.DataVolumeSource, obj client.Object, sup supplements.DataVolumeSupplement, opts ...service.Option) error { -// panic("mock out the Start method") +// WaitForImportFunc: func(ctx context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) (corev1.PodPhase, error) { +// panic("mock out the WaitForImport method") // }, // } // -// // use mockedObjectRefVirtualImageDiskService in code that requires ObjectRefVirtualImageDiskService +// // use mockedDataSourcePVCService in code that requires DataSourcePVCService // // and then make assertions. // // } -type ObjectRefVirtualImageDiskServiceMock struct { - // CheckProvisioningFunc mocks the CheckProvisioning method. - CheckProvisioningFunc func(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error +type DataSourcePVCServiceMock struct { + // CreateBlankTargetFunc mocks the CreateBlankTarget method. + CreateBlankTargetFunc func(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, modeGetter step.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) - // CleanUpFunc mocks the CleanUp method. - CleanUpFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + // CreateTargetFromDVCRFunc mocks the CreateTargetFromDVCR method. + CreateTargetFromDVCRFunc func(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *service.PVCImportSourceRegistry, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) - // CleanUpSupplementsFunc mocks the CleanUpSupplements method. - CleanUpSupplementsFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + // CreateTargetFromPVCFunc mocks the CreateTargetFromPVC method. + CreateTargetFromPVCFunc func(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *corev1.PersistentVolumeClaim, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) - // GetCapacityFunc mocks the GetCapacity method. - GetCapacityFunc func(pvc *corev1.PersistentVolumeClaim) string + // CreateTargetFromVSFunc mocks the CreateTargetFromVS method. + CreateTargetFromVSFunc func(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) - // GetProgressFunc mocks the GetProgress method. - GetProgressFunc func(dv *cdiv1.DataVolume, prevProgress string, opts ...service.GetProgressOption) string + // FinalizersFunc mocks the Finalizers method. + FinalizersFunc func() []string - // ProtectFunc mocks the Protect method. - ProtectFunc func(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error + // ImportFunc mocks the Import method. + ImportFunc func(ctx context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) error - // StartFunc mocks the Start method. - StartFunc func(ctx context.Context, pvcSize resource.Quantity, sc *storagev1.StorageClass, source *cdiv1.DataVolumeSource, obj client.Object, sup supplements.DataVolumeSupplement, opts ...service.Option) error + // WaitForImportFunc mocks the WaitForImport method. + WaitForImportFunc func(ctx context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) (corev1.PodPhase, error) // calls tracks calls to the methods. calls struct { - // CheckProvisioning holds details about calls to the CheckProvisioning method. - CheckProvisioning []struct { + // CreateBlankTarget holds details about calls to the CreateBlankTarget method. + CreateBlankTarget []struct { // Ctx is the ctx argument value. Ctx context.Context - // Pvc is the pvc argument value. - Pvc *corev1.PersistentVolumeClaim + // Key is the key argument value. + Key types.NamespacedName + // StorageClassName is the storageClassName argument value. + StorageClassName string + // Size is the size argument value. + Size *resource.Quantity + // Owner is the owner argument value. + Owner client.Object + // ModeGetter is the modeGetter argument value. + ModeGetter step.VolumeAndAccessModesGetter + // NodePlacement is the nodePlacement argument value. + NodePlacement *provisioner.NodePlacement } - // CleanUp holds details about calls to the CleanUp method. - CleanUp []struct { + // CreateTargetFromDVCR holds details about calls to the CreateTargetFromDVCR method. + CreateTargetFromDVCR []struct { // Ctx is the ctx argument value. Ctx context.Context - // Sup is the sup argument value. - Sup supplements.Generator + // Key is the key argument value. + Key types.NamespacedName + // StorageClassName is the storageClassName argument value. + StorageClassName string + // Size is the size argument value. + Size *resource.Quantity + // Owner is the owner argument value. + Owner client.Object + // Source is the source argument value. + Source *service.PVCImportSourceRegistry + // ModeGetter is the modeGetter argument value. + ModeGetter service.VolumeAndAccessModesGetter + // NodePlacement is the nodePlacement argument value. + NodePlacement *provisioner.NodePlacement } - // CleanUpSupplements holds details about calls to the CleanUpSupplements method. - CleanUpSupplements []struct { + // CreateTargetFromPVC holds details about calls to the CreateTargetFromPVC method. + CreateTargetFromPVC []struct { // Ctx is the ctx argument value. Ctx context.Context - // Sup is the sup argument value. - Sup supplements.Generator + // Key is the key argument value. + Key types.NamespacedName + // StorageClassName is the storageClassName argument value. + StorageClassName string + // Size is the size argument value. + Size *resource.Quantity + // Owner is the owner argument value. + Owner client.Object + // Source is the source argument value. + Source *corev1.PersistentVolumeClaim + // ModeGetter is the modeGetter argument value. + ModeGetter service.VolumeAndAccessModesGetter + // NodePlacement is the nodePlacement argument value. + NodePlacement *provisioner.NodePlacement } - // GetCapacity holds details about calls to the GetCapacity method. - GetCapacity []struct { - // Pvc is the pvc argument value. - Pvc *corev1.PersistentVolumeClaim + // CreateTargetFromVS holds details about calls to the CreateTargetFromVS method. + CreateTargetFromVS []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Key is the key argument value. + Key types.NamespacedName + // StorageClassName is the storageClassName argument value. + StorageClassName string + // Size is the size argument value. + Size *resource.Quantity + // Owner is the owner argument value. + Owner client.Object + // Source is the source argument value. + Source *vsv1.VolumeSnapshot + // ModeGetter is the modeGetter argument value. + ModeGetter service.VolumeAndAccessModesGetter + // NodePlacement is the nodePlacement argument value. + NodePlacement *provisioner.NodePlacement } - // GetProgress holds details about calls to the GetProgress method. - GetProgress []struct { - // Dv is the dv argument value. - Dv *cdiv1.DataVolume - // PrevProgress is the prevProgress argument value. - PrevProgress string - // Opts is the opts argument value. - Opts []service.GetProgressOption + // Finalizers holds details about calls to the Finalizers method. + Finalizers []struct { } - // Protect holds details about calls to the Protect method. - Protect []struct { + // Import holds details about calls to the Import method. + Import []struct { // Ctx is the ctx argument value. Ctx context.Context - // Sup is the sup argument value. - Sup supplements.Generator + // Target is the target argument value. + Target *corev1.PersistentVolumeClaim + // Source is the source argument value. + Source *service.PVCImportSource // Owner is the owner argument value. Owner client.Object - // Dv is the dv argument value. - Dv *cdiv1.DataVolume - // Pvc is the pvc argument value. - Pvc *corev1.PersistentVolumeClaim + // Sup is the sup argument value. + Sup supplements.Generator + // NodePlacement is the nodePlacement argument value. + NodePlacement *provisioner.NodePlacement } - // Start holds details about calls to the Start method. - Start []struct { + // WaitForImport holds details about calls to the WaitForImport method. + WaitForImport []struct { // Ctx is the ctx argument value. Ctx context.Context - // PvcSize is the pvcSize argument value. - PvcSize resource.Quantity - // Sc is the sc argument value. - Sc *storagev1.StorageClass + // Target is the target argument value. + Target *corev1.PersistentVolumeClaim // Source is the source argument value. - Source *cdiv1.DataVolumeSource - // Obj is the obj argument value. - Obj client.Object + Source *service.PVCImportSource + // Owner is the owner argument value. + Owner client.Object // Sup is the sup argument value. - Sup supplements.DataVolumeSupplement - // Opts is the opts argument value. - Opts []service.Option + Sup supplements.Generator + // NodePlacement is the nodePlacement argument value. + NodePlacement *provisioner.NodePlacement } } - lockCheckProvisioning sync.RWMutex - lockCleanUp sync.RWMutex - lockCleanUpSupplements sync.RWMutex - lockGetCapacity sync.RWMutex - lockGetProgress sync.RWMutex - lockProtect sync.RWMutex - lockStart sync.RWMutex + lockCreateBlankTarget sync.RWMutex + lockCreateTargetFromDVCR sync.RWMutex + lockCreateTargetFromPVC sync.RWMutex + lockCreateTargetFromVS sync.RWMutex + lockFinalizers sync.RWMutex + lockImport sync.RWMutex + lockWaitForImport sync.RWMutex } -// CheckProvisioning calls CheckProvisioningFunc. -func (mock *ObjectRefVirtualImageDiskServiceMock) CheckProvisioning(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { - if mock.CheckProvisioningFunc == nil { - panic("ObjectRefVirtualImageDiskServiceMock.CheckProvisioningFunc: method is nil but ObjectRefVirtualImageDiskService.CheckProvisioning was just called") +// CreateBlankTarget calls CreateBlankTargetFunc. +func (mock *DataSourcePVCServiceMock) CreateBlankTarget(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, modeGetter step.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + if mock.CreateBlankTargetFunc == nil { + panic("DataSourcePVCServiceMock.CreateBlankTargetFunc: method is nil but DataSourcePVCService.CreateBlankTarget was just called") } callInfo := struct { - Ctx context.Context - Pvc *corev1.PersistentVolumeClaim + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + ModeGetter step.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement }{ - Ctx: ctx, - Pvc: pvc, + Ctx: ctx, + Key: key, + StorageClassName: storageClassName, + Size: size, + Owner: owner, + ModeGetter: modeGetter, + NodePlacement: nodePlacement, } - mock.lockCheckProvisioning.Lock() - mock.calls.CheckProvisioning = append(mock.calls.CheckProvisioning, callInfo) - mock.lockCheckProvisioning.Unlock() - return mock.CheckProvisioningFunc(ctx, pvc) + mock.lockCreateBlankTarget.Lock() + mock.calls.CreateBlankTarget = append(mock.calls.CreateBlankTarget, callInfo) + mock.lockCreateBlankTarget.Unlock() + return mock.CreateBlankTargetFunc(ctx, key, storageClassName, size, owner, modeGetter, nodePlacement) } -// CheckProvisioningCalls gets all the calls that were made to CheckProvisioning. +// CreateBlankTargetCalls gets all the calls that were made to CreateBlankTarget. // Check the length with: // -// len(mockedObjectRefVirtualImageDiskService.CheckProvisioningCalls()) -func (mock *ObjectRefVirtualImageDiskServiceMock) CheckProvisioningCalls() []struct { - Ctx context.Context - Pvc *corev1.PersistentVolumeClaim +// len(mockedDataSourcePVCService.CreateBlankTargetCalls()) +func (mock *DataSourcePVCServiceMock) CreateBlankTargetCalls() []struct { + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + ModeGetter step.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement } { var calls []struct { - Ctx context.Context - Pvc *corev1.PersistentVolumeClaim + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + ModeGetter step.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement } - mock.lockCheckProvisioning.RLock() - calls = mock.calls.CheckProvisioning - mock.lockCheckProvisioning.RUnlock() + mock.lockCreateBlankTarget.RLock() + calls = mock.calls.CreateBlankTarget + mock.lockCreateBlankTarget.RUnlock() return calls } -// CleanUp calls CleanUpFunc. -func (mock *ObjectRefVirtualImageDiskServiceMock) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { - if mock.CleanUpFunc == nil { - panic("ObjectRefVirtualImageDiskServiceMock.CleanUpFunc: method is nil but ObjectRefVirtualImageDiskService.CleanUp was just called") +// CreateTargetFromDVCR calls CreateTargetFromDVCRFunc. +func (mock *DataSourcePVCServiceMock) CreateTargetFromDVCR(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *service.PVCImportSourceRegistry, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + if mock.CreateTargetFromDVCRFunc == nil { + panic("DataSourcePVCServiceMock.CreateTargetFromDVCRFunc: method is nil but DataSourcePVCService.CreateTargetFromDVCR was just called") } callInfo := struct { - Ctx context.Context - Sup supplements.Generator + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *service.PVCImportSourceRegistry + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement }{ - Ctx: ctx, - Sup: sup, + Ctx: ctx, + Key: key, + StorageClassName: storageClassName, + Size: size, + Owner: owner, + Source: source, + ModeGetter: modeGetter, + NodePlacement: nodePlacement, } - mock.lockCleanUp.Lock() - mock.calls.CleanUp = append(mock.calls.CleanUp, callInfo) - mock.lockCleanUp.Unlock() - return mock.CleanUpFunc(ctx, sup) + mock.lockCreateTargetFromDVCR.Lock() + mock.calls.CreateTargetFromDVCR = append(mock.calls.CreateTargetFromDVCR, callInfo) + mock.lockCreateTargetFromDVCR.Unlock() + return mock.CreateTargetFromDVCRFunc(ctx, key, storageClassName, size, owner, source, modeGetter, nodePlacement) } -// CleanUpCalls gets all the calls that were made to CleanUp. +// CreateTargetFromDVCRCalls gets all the calls that were made to CreateTargetFromDVCR. // Check the length with: // -// len(mockedObjectRefVirtualImageDiskService.CleanUpCalls()) -func (mock *ObjectRefVirtualImageDiskServiceMock) CleanUpCalls() []struct { - Ctx context.Context - Sup supplements.Generator +// len(mockedDataSourcePVCService.CreateTargetFromDVCRCalls()) +func (mock *DataSourcePVCServiceMock) CreateTargetFromDVCRCalls() []struct { + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *service.PVCImportSourceRegistry + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement } { var calls []struct { - Ctx context.Context - Sup supplements.Generator + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *service.PVCImportSourceRegistry + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement } - mock.lockCleanUp.RLock() - calls = mock.calls.CleanUp - mock.lockCleanUp.RUnlock() + mock.lockCreateTargetFromDVCR.RLock() + calls = mock.calls.CreateTargetFromDVCR + mock.lockCreateTargetFromDVCR.RUnlock() return calls } -// CleanUpSupplements calls CleanUpSupplementsFunc. -func (mock *ObjectRefVirtualImageDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { - if mock.CleanUpSupplementsFunc == nil { - panic("ObjectRefVirtualImageDiskServiceMock.CleanUpSupplementsFunc: method is nil but ObjectRefVirtualImageDiskService.CleanUpSupplements was just called") +// CreateTargetFromPVC calls CreateTargetFromPVCFunc. +func (mock *DataSourcePVCServiceMock) CreateTargetFromPVC(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *corev1.PersistentVolumeClaim, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + if mock.CreateTargetFromPVCFunc == nil { + panic("DataSourcePVCServiceMock.CreateTargetFromPVCFunc: method is nil but DataSourcePVCService.CreateTargetFromPVC was just called") } callInfo := struct { - Ctx context.Context - Sup supplements.Generator + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *corev1.PersistentVolumeClaim + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement }{ - Ctx: ctx, - Sup: sup, + Ctx: ctx, + Key: key, + StorageClassName: storageClassName, + Size: size, + Owner: owner, + Source: source, + ModeGetter: modeGetter, + NodePlacement: nodePlacement, } - mock.lockCleanUpSupplements.Lock() - mock.calls.CleanUpSupplements = append(mock.calls.CleanUpSupplements, callInfo) - mock.lockCleanUpSupplements.Unlock() - return mock.CleanUpSupplementsFunc(ctx, sup) + mock.lockCreateTargetFromPVC.Lock() + mock.calls.CreateTargetFromPVC = append(mock.calls.CreateTargetFromPVC, callInfo) + mock.lockCreateTargetFromPVC.Unlock() + return mock.CreateTargetFromPVCFunc(ctx, key, storageClassName, size, owner, source, modeGetter, nodePlacement) } -// CleanUpSupplementsCalls gets all the calls that were made to CleanUpSupplements. +// CreateTargetFromPVCCalls gets all the calls that were made to CreateTargetFromPVC. // Check the length with: // -// len(mockedObjectRefVirtualImageDiskService.CleanUpSupplementsCalls()) -func (mock *ObjectRefVirtualImageDiskServiceMock) CleanUpSupplementsCalls() []struct { - Ctx context.Context - Sup supplements.Generator +// len(mockedDataSourcePVCService.CreateTargetFromPVCCalls()) +func (mock *DataSourcePVCServiceMock) CreateTargetFromPVCCalls() []struct { + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *corev1.PersistentVolumeClaim + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement } { var calls []struct { - Ctx context.Context - Sup supplements.Generator + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *corev1.PersistentVolumeClaim + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement } - mock.lockCleanUpSupplements.RLock() - calls = mock.calls.CleanUpSupplements - mock.lockCleanUpSupplements.RUnlock() + mock.lockCreateTargetFromPVC.RLock() + calls = mock.calls.CreateTargetFromPVC + mock.lockCreateTargetFromPVC.RUnlock() return calls } -// GetCapacity calls GetCapacityFunc. -func (mock *ObjectRefVirtualImageDiskServiceMock) GetCapacity(pvc *corev1.PersistentVolumeClaim) string { - if mock.GetCapacityFunc == nil { - panic("ObjectRefVirtualImageDiskServiceMock.GetCapacityFunc: method is nil but ObjectRefVirtualImageDiskService.GetCapacity was just called") +// CreateTargetFromVS calls CreateTargetFromVSFunc. +func (mock *DataSourcePVCServiceMock) CreateTargetFromVS(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + if mock.CreateTargetFromVSFunc == nil { + panic("DataSourcePVCServiceMock.CreateTargetFromVSFunc: method is nil but DataSourcePVCService.CreateTargetFromVS was just called") } callInfo := struct { - Pvc *corev1.PersistentVolumeClaim + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *vsv1.VolumeSnapshot + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement }{ - Pvc: pvc, + Ctx: ctx, + Key: key, + StorageClassName: storageClassName, + Size: size, + Owner: owner, + Source: source, + ModeGetter: modeGetter, + NodePlacement: nodePlacement, } - mock.lockGetCapacity.Lock() - mock.calls.GetCapacity = append(mock.calls.GetCapacity, callInfo) - mock.lockGetCapacity.Unlock() - return mock.GetCapacityFunc(pvc) + mock.lockCreateTargetFromVS.Lock() + mock.calls.CreateTargetFromVS = append(mock.calls.CreateTargetFromVS, callInfo) + mock.lockCreateTargetFromVS.Unlock() + return mock.CreateTargetFromVSFunc(ctx, key, storageClassName, size, owner, source, modeGetter, nodePlacement) } -// GetCapacityCalls gets all the calls that were made to GetCapacity. +// CreateTargetFromVSCalls gets all the calls that were made to CreateTargetFromVS. // Check the length with: // -// len(mockedObjectRefVirtualImageDiskService.GetCapacityCalls()) -func (mock *ObjectRefVirtualImageDiskServiceMock) GetCapacityCalls() []struct { - Pvc *corev1.PersistentVolumeClaim +// len(mockedDataSourcePVCService.CreateTargetFromVSCalls()) +func (mock *DataSourcePVCServiceMock) CreateTargetFromVSCalls() []struct { + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *vsv1.VolumeSnapshot + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement } { var calls []struct { - Pvc *corev1.PersistentVolumeClaim + Ctx context.Context + Key types.NamespacedName + StorageClassName string + Size *resource.Quantity + Owner client.Object + Source *vsv1.VolumeSnapshot + ModeGetter service.VolumeAndAccessModesGetter + NodePlacement *provisioner.NodePlacement } - mock.lockGetCapacity.RLock() - calls = mock.calls.GetCapacity - mock.lockGetCapacity.RUnlock() + mock.lockCreateTargetFromVS.RLock() + calls = mock.calls.CreateTargetFromVS + mock.lockCreateTargetFromVS.RUnlock() return calls } -// GetProgress calls GetProgressFunc. -func (mock *ObjectRefVirtualImageDiskServiceMock) GetProgress(dv *cdiv1.DataVolume, prevProgress string, opts ...service.GetProgressOption) string { - if mock.GetProgressFunc == nil { - panic("ObjectRefVirtualImageDiskServiceMock.GetProgressFunc: method is nil but ObjectRefVirtualImageDiskService.GetProgress was just called") +// Finalizers calls FinalizersFunc. +func (mock *DataSourcePVCServiceMock) Finalizers() []string { + if mock.FinalizersFunc == nil { + panic("DataSourcePVCServiceMock.FinalizersFunc: method is nil but DataSourcePVCService.Finalizers was just called") } callInfo := struct { - Dv *cdiv1.DataVolume - PrevProgress string - Opts []service.GetProgressOption - }{ - Dv: dv, - PrevProgress: prevProgress, - Opts: opts, - } - mock.lockGetProgress.Lock() - mock.calls.GetProgress = append(mock.calls.GetProgress, callInfo) - mock.lockGetProgress.Unlock() - return mock.GetProgressFunc(dv, prevProgress, opts...) + }{} + mock.lockFinalizers.Lock() + mock.calls.Finalizers = append(mock.calls.Finalizers, callInfo) + mock.lockFinalizers.Unlock() + return mock.FinalizersFunc() } -// GetProgressCalls gets all the calls that were made to GetProgress. +// FinalizersCalls gets all the calls that were made to Finalizers. // Check the length with: // -// len(mockedObjectRefVirtualImageDiskService.GetProgressCalls()) -func (mock *ObjectRefVirtualImageDiskServiceMock) GetProgressCalls() []struct { - Dv *cdiv1.DataVolume - PrevProgress string - Opts []service.GetProgressOption +// len(mockedDataSourcePVCService.FinalizersCalls()) +func (mock *DataSourcePVCServiceMock) FinalizersCalls() []struct { } { var calls []struct { - Dv *cdiv1.DataVolume - PrevProgress string - Opts []service.GetProgressOption } - mock.lockGetProgress.RLock() - calls = mock.calls.GetProgress - mock.lockGetProgress.RUnlock() + mock.lockFinalizers.RLock() + calls = mock.calls.Finalizers + mock.lockFinalizers.RUnlock() return calls } -// Protect calls ProtectFunc. -func (mock *ObjectRefVirtualImageDiskServiceMock) Protect(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { - if mock.ProtectFunc == nil { - panic("ObjectRefVirtualImageDiskServiceMock.ProtectFunc: method is nil but ObjectRefVirtualImageDiskService.Protect was just called") +// Import calls ImportFunc. +func (mock *DataSourcePVCServiceMock) Import(ctx context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) error { + if mock.ImportFunc == nil { + panic("DataSourcePVCServiceMock.ImportFunc: method is nil but DataSourcePVCService.Import was just called") } callInfo := struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim + Ctx context.Context + Target *corev1.PersistentVolumeClaim + Source *service.PVCImportSource + Owner client.Object + Sup supplements.Generator + NodePlacement *provisioner.NodePlacement }{ - Ctx: ctx, - Sup: sup, - Owner: owner, - Dv: dv, - Pvc: pvc, + Ctx: ctx, + Target: target, + Source: source, + Owner: owner, + Sup: sup, + NodePlacement: nodePlacement, } - mock.lockProtect.Lock() - mock.calls.Protect = append(mock.calls.Protect, callInfo) - mock.lockProtect.Unlock() - return mock.ProtectFunc(ctx, sup, owner, dv, pvc) + mock.lockImport.Lock() + mock.calls.Import = append(mock.calls.Import, callInfo) + mock.lockImport.Unlock() + return mock.ImportFunc(ctx, target, source, owner, sup, nodePlacement) } -// ProtectCalls gets all the calls that were made to Protect. +// ImportCalls gets all the calls that were made to Import. // Check the length with: // -// len(mockedObjectRefVirtualImageDiskService.ProtectCalls()) -func (mock *ObjectRefVirtualImageDiskServiceMock) ProtectCalls() []struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim +// len(mockedDataSourcePVCService.ImportCalls()) +func (mock *DataSourcePVCServiceMock) ImportCalls() []struct { + Ctx context.Context + Target *corev1.PersistentVolumeClaim + Source *service.PVCImportSource + Owner client.Object + Sup supplements.Generator + NodePlacement *provisioner.NodePlacement } { var calls []struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim + Ctx context.Context + Target *corev1.PersistentVolumeClaim + Source *service.PVCImportSource + Owner client.Object + Sup supplements.Generator + NodePlacement *provisioner.NodePlacement } - mock.lockProtect.RLock() - calls = mock.calls.Protect - mock.lockProtect.RUnlock() + mock.lockImport.RLock() + calls = mock.calls.Import + mock.lockImport.RUnlock() return calls } -// Start calls StartFunc. -func (mock *ObjectRefVirtualImageDiskServiceMock) Start(ctx context.Context, pvcSize resource.Quantity, sc *storagev1.StorageClass, source *cdiv1.DataVolumeSource, obj client.Object, sup supplements.DataVolumeSupplement, opts ...service.Option) error { - if mock.StartFunc == nil { - panic("ObjectRefVirtualImageDiskServiceMock.StartFunc: method is nil but ObjectRefVirtualImageDiskService.Start was just called") +// WaitForImport calls WaitForImportFunc. +func (mock *DataSourcePVCServiceMock) WaitForImport(ctx context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) (corev1.PodPhase, error) { + if mock.WaitForImportFunc == nil { + panic("DataSourcePVCServiceMock.WaitForImportFunc: method is nil but DataSourcePVCService.WaitForImport was just called") } callInfo := struct { - Ctx context.Context - PvcSize resource.Quantity - Sc *storagev1.StorageClass - Source *cdiv1.DataVolumeSource - Obj client.Object - Sup supplements.DataVolumeSupplement - Opts []service.Option + Ctx context.Context + Target *corev1.PersistentVolumeClaim + Source *service.PVCImportSource + Owner client.Object + Sup supplements.Generator + NodePlacement *provisioner.NodePlacement }{ - Ctx: ctx, - PvcSize: pvcSize, - Sc: sc, - Source: source, - Obj: obj, - Sup: sup, - Opts: opts, + Ctx: ctx, + Target: target, + Source: source, + Owner: owner, + Sup: sup, + NodePlacement: nodePlacement, } - mock.lockStart.Lock() - mock.calls.Start = append(mock.calls.Start, callInfo) - mock.lockStart.Unlock() - return mock.StartFunc(ctx, pvcSize, sc, source, obj, sup, opts...) + mock.lockWaitForImport.Lock() + mock.calls.WaitForImport = append(mock.calls.WaitForImport, callInfo) + mock.lockWaitForImport.Unlock() + return mock.WaitForImportFunc(ctx, target, source, owner, sup, nodePlacement) } -// StartCalls gets all the calls that were made to Start. +// WaitForImportCalls gets all the calls that were made to WaitForImport. // Check the length with: // -// len(mockedObjectRefVirtualImageDiskService.StartCalls()) -func (mock *ObjectRefVirtualImageDiskServiceMock) StartCalls() []struct { - Ctx context.Context - PvcSize resource.Quantity - Sc *storagev1.StorageClass - Source *cdiv1.DataVolumeSource - Obj client.Object - Sup supplements.DataVolumeSupplement - Opts []service.Option +// len(mockedDataSourcePVCService.WaitForImportCalls()) +func (mock *DataSourcePVCServiceMock) WaitForImportCalls() []struct { + Ctx context.Context + Target *corev1.PersistentVolumeClaim + Source *service.PVCImportSource + Owner client.Object + Sup supplements.Generator + NodePlacement *provisioner.NodePlacement } { var calls []struct { - Ctx context.Context - PvcSize resource.Quantity - Sc *storagev1.StorageClass - Source *cdiv1.DataVolumeSource - Obj client.Object - Sup supplements.DataVolumeSupplement - Opts []service.Option + Ctx context.Context + Target *corev1.PersistentVolumeClaim + Source *service.PVCImportSource + Owner client.Object + Sup supplements.Generator + NodePlacement *provisioner.NodePlacement } - mock.lockStart.RLock() - calls = mock.calls.Start - mock.lockStart.RUnlock() + mock.lockWaitForImport.RLock() + calls = mock.calls.WaitForImport + mock.lockWaitForImport.RUnlock() return calls } -// Ensure, that ObjectRefClusterVirtualImageDiskServiceMock does implement ObjectRefClusterVirtualImageDiskService. +// Ensure, that ObjectRefVirtualImageDiskServiceMock does implement ObjectRefVirtualImageDiskService. // If this is not the case, regenerate this file with moq. -var _ ObjectRefClusterVirtualImageDiskService = &ObjectRefClusterVirtualImageDiskServiceMock{} +var _ ObjectRefVirtualImageDiskService = &ObjectRefVirtualImageDiskServiceMock{} -// ObjectRefClusterVirtualImageDiskServiceMock is a mock implementation of ObjectRefClusterVirtualImageDiskService. +// ObjectRefVirtualImageDiskServiceMock is a mock implementation of ObjectRefVirtualImageDiskService. // -// func TestSomethingThatUsesObjectRefClusterVirtualImageDiskService(t *testing.T) { +// func TestSomethingThatUsesObjectRefVirtualImageDiskService(t *testing.T) { // -// // make and configure a mocked ObjectRefClusterVirtualImageDiskService -// mockedObjectRefClusterVirtualImageDiskService := &ObjectRefClusterVirtualImageDiskServiceMock{ -// CheckProvisioningFunc: func(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { -// panic("mock out the CheckProvisioning method") -// }, -// CleanUpFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { -// panic("mock out the CleanUp method") -// }, +// // make and configure a mocked ObjectRefVirtualImageDiskService +// mockedObjectRefVirtualImageDiskService := &ObjectRefVirtualImageDiskServiceMock{ // CleanUpSupplementsFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { // panic("mock out the CleanUpSupplements method") // }, // GetCapacityFunc: func(pvc *corev1.PersistentVolumeClaim) string { // panic("mock out the GetCapacity method") // }, -// GetProgressFunc: func(dv *cdiv1.DataVolume, prevProgress string, opts ...service.GetProgressOption) string { -// panic("mock out the GetProgress method") -// }, -// ProtectFunc: func(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { -// panic("mock out the Protect method") -// }, -// StartFunc: func(ctx context.Context, pvcSize resource.Quantity, sc *storagev1.StorageClass, source *cdiv1.DataVolumeSource, obj client.Object, sup supplements.DataVolumeSupplement, opts ...service.Option) error { -// panic("mock out the Start method") +// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +// panic("mock out the GetVolumeAndAccessModes method") // }, // } // -// // use mockedObjectRefClusterVirtualImageDiskService in code that requires ObjectRefClusterVirtualImageDiskService +// // use mockedObjectRefVirtualImageDiskService in code that requires ObjectRefVirtualImageDiskService // // and then make assertions. // // } -type ObjectRefClusterVirtualImageDiskServiceMock struct { - // CheckProvisioningFunc mocks the CheckProvisioning method. - CheckProvisioningFunc func(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error - - // CleanUpFunc mocks the CleanUp method. - CleanUpFunc func(ctx context.Context, sup supplements.Generator) (bool, error) - +type ObjectRefVirtualImageDiskServiceMock struct { // CleanUpSupplementsFunc mocks the CleanUpSupplements method. CleanUpSupplementsFunc func(ctx context.Context, sup supplements.Generator) (bool, error) // GetCapacityFunc mocks the GetCapacity method. GetCapacityFunc func(pvc *corev1.PersistentVolumeClaim) string - // GetProgressFunc mocks the GetProgress method. - GetProgressFunc func(dv *cdiv1.DataVolume, prevProgress string, opts ...service.GetProgressOption) string - - // ProtectFunc mocks the Protect method. - ProtectFunc func(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error - - // StartFunc mocks the Start method. - StartFunc func(ctx context.Context, pvcSize resource.Quantity, sc *storagev1.StorageClass, source *cdiv1.DataVolumeSource, obj client.Object, sup supplements.DataVolumeSupplement, opts ...service.Option) error + // GetVolumeAndAccessModesFunc mocks the GetVolumeAndAccessModes method. + GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) // calls tracks calls to the methods. calls struct { - // CheckProvisioning holds details about calls to the CheckProvisioning method. - CheckProvisioning []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Pvc is the pvc argument value. - Pvc *corev1.PersistentVolumeClaim - } - // CleanUp holds details about calls to the CleanUp method. - CleanUp []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Sup is the sup argument value. - Sup supplements.Generator - } // CleanUpSupplements holds details about calls to the CleanUpSupplements method. CleanUpSupplements []struct { // Ctx is the ctx argument value. @@ -1023,131 +1049,25 @@ type ObjectRefClusterVirtualImageDiskServiceMock struct { // Pvc is the pvc argument value. Pvc *corev1.PersistentVolumeClaim } - // GetProgress holds details about calls to the GetProgress method. - GetProgress []struct { - // Dv is the dv argument value. - Dv *cdiv1.DataVolume - // PrevProgress is the prevProgress argument value. - PrevProgress string - // Opts is the opts argument value. - Opts []service.GetProgressOption - } - // Protect holds details about calls to the Protect method. - Protect []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Sup is the sup argument value. - Sup supplements.Generator - // Owner is the owner argument value. - Owner client.Object - // Dv is the dv argument value. - Dv *cdiv1.DataVolume - // Pvc is the pvc argument value. - Pvc *corev1.PersistentVolumeClaim - } - // Start holds details about calls to the Start method. - Start []struct { + // GetVolumeAndAccessModes holds details about calls to the GetVolumeAndAccessModes method. + GetVolumeAndAccessModes []struct { // Ctx is the ctx argument value. Ctx context.Context - // PvcSize is the pvcSize argument value. - PvcSize resource.Quantity - // Sc is the sc argument value. - Sc *storagev1.StorageClass - // Source is the source argument value. - Source *cdiv1.DataVolumeSource // Obj is the obj argument value. Obj client.Object - // Sup is the sup argument value. - Sup supplements.DataVolumeSupplement - // Opts is the opts argument value. - Opts []service.Option + // Sc is the sc argument value. + Sc *v1.StorageClass } } - lockCheckProvisioning sync.RWMutex - lockCleanUp sync.RWMutex - lockCleanUpSupplements sync.RWMutex - lockGetCapacity sync.RWMutex - lockGetProgress sync.RWMutex - lockProtect sync.RWMutex - lockStart sync.RWMutex -} - -// CheckProvisioning calls CheckProvisioningFunc. -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CheckProvisioning(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { - if mock.CheckProvisioningFunc == nil { - panic("ObjectRefClusterVirtualImageDiskServiceMock.CheckProvisioningFunc: method is nil but ObjectRefClusterVirtualImageDiskService.CheckProvisioning was just called") - } - callInfo := struct { - Ctx context.Context - Pvc *corev1.PersistentVolumeClaim - }{ - Ctx: ctx, - Pvc: pvc, - } - mock.lockCheckProvisioning.Lock() - mock.calls.CheckProvisioning = append(mock.calls.CheckProvisioning, callInfo) - mock.lockCheckProvisioning.Unlock() - return mock.CheckProvisioningFunc(ctx, pvc) -} - -// CheckProvisioningCalls gets all the calls that were made to CheckProvisioning. -// Check the length with: -// -// len(mockedObjectRefClusterVirtualImageDiskService.CheckProvisioningCalls()) -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CheckProvisioningCalls() []struct { - Ctx context.Context - Pvc *corev1.PersistentVolumeClaim -} { - var calls []struct { - Ctx context.Context - Pvc *corev1.PersistentVolumeClaim - } - mock.lockCheckProvisioning.RLock() - calls = mock.calls.CheckProvisioning - mock.lockCheckProvisioning.RUnlock() - return calls -} - -// CleanUp calls CleanUpFunc. -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { - if mock.CleanUpFunc == nil { - panic("ObjectRefClusterVirtualImageDiskServiceMock.CleanUpFunc: method is nil but ObjectRefClusterVirtualImageDiskService.CleanUp was just called") - } - callInfo := struct { - Ctx context.Context - Sup supplements.Generator - }{ - Ctx: ctx, - Sup: sup, - } - mock.lockCleanUp.Lock() - mock.calls.CleanUp = append(mock.calls.CleanUp, callInfo) - mock.lockCleanUp.Unlock() - return mock.CleanUpFunc(ctx, sup) -} - -// CleanUpCalls gets all the calls that were made to CleanUp. -// Check the length with: -// -// len(mockedObjectRefClusterVirtualImageDiskService.CleanUpCalls()) -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CleanUpCalls() []struct { - Ctx context.Context - Sup supplements.Generator -} { - var calls []struct { - Ctx context.Context - Sup supplements.Generator - } - mock.lockCleanUp.RLock() - calls = mock.calls.CleanUp - mock.lockCleanUp.RUnlock() - return calls + lockCleanUpSupplements sync.RWMutex + lockGetCapacity sync.RWMutex + lockGetVolumeAndAccessModes sync.RWMutex } // CleanUpSupplements calls CleanUpSupplementsFunc. -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { +func (mock *ObjectRefVirtualImageDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { if mock.CleanUpSupplementsFunc == nil { - panic("ObjectRefClusterVirtualImageDiskServiceMock.CleanUpSupplementsFunc: method is nil but ObjectRefClusterVirtualImageDiskService.CleanUpSupplements was just called") + panic("ObjectRefVirtualImageDiskServiceMock.CleanUpSupplementsFunc: method is nil but ObjectRefVirtualImageDiskService.CleanUpSupplements was just called") } callInfo := struct { Ctx context.Context @@ -1165,8 +1085,8 @@ func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CleanUpSupplements(ctx // CleanUpSupplementsCalls gets all the calls that were made to CleanUpSupplements. // Check the length with: // -// len(mockedObjectRefClusterVirtualImageDiskService.CleanUpSupplementsCalls()) -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CleanUpSupplementsCalls() []struct { +// len(mockedObjectRefVirtualImageDiskService.CleanUpSupplementsCalls()) +func (mock *ObjectRefVirtualImageDiskServiceMock) CleanUpSupplementsCalls() []struct { Ctx context.Context Sup supplements.Generator } { @@ -1181,9 +1101,9 @@ func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CleanUpSupplementsCalls } // GetCapacity calls GetCapacityFunc. -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetCapacity(pvc *corev1.PersistentVolumeClaim) string { +func (mock *ObjectRefVirtualImageDiskServiceMock) GetCapacity(pvc *corev1.PersistentVolumeClaim) string { if mock.GetCapacityFunc == nil { - panic("ObjectRefClusterVirtualImageDiskServiceMock.GetCapacityFunc: method is nil but ObjectRefClusterVirtualImageDiskService.GetCapacity was just called") + panic("ObjectRefVirtualImageDiskServiceMock.GetCapacityFunc: method is nil but ObjectRefVirtualImageDiskService.GetCapacity was just called") } callInfo := struct { Pvc *corev1.PersistentVolumeClaim @@ -1199,8 +1119,8 @@ func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetCapacity(pvc *corev1 // GetCapacityCalls gets all the calls that were made to GetCapacity. // Check the length with: // -// len(mockedObjectRefClusterVirtualImageDiskService.GetCapacityCalls()) -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetCapacityCalls() []struct { +// len(mockedObjectRefVirtualImageDiskService.GetCapacityCalls()) +func (mock *ObjectRefVirtualImageDiskServiceMock) GetCapacityCalls() []struct { Pvc *corev1.PersistentVolumeClaim } { var calls []struct { @@ -1212,184 +1132,164 @@ func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetCapacityCalls() []st return calls } -// GetProgress calls GetProgressFunc. -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetProgress(dv *cdiv1.DataVolume, prevProgress string, opts ...service.GetProgressOption) string { - if mock.GetProgressFunc == nil { - panic("ObjectRefClusterVirtualImageDiskServiceMock.GetProgressFunc: method is nil but ObjectRefClusterVirtualImageDiskService.GetProgress was just called") +// GetVolumeAndAccessModes calls GetVolumeAndAccessModesFunc. +func (mock *ObjectRefVirtualImageDiskServiceMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + if mock.GetVolumeAndAccessModesFunc == nil { + panic("ObjectRefVirtualImageDiskServiceMock.GetVolumeAndAccessModesFunc: method is nil but ObjectRefVirtualImageDiskService.GetVolumeAndAccessModes was just called") } callInfo := struct { - Dv *cdiv1.DataVolume - PrevProgress string - Opts []service.GetProgressOption + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass }{ - Dv: dv, - PrevProgress: prevProgress, - Opts: opts, + Ctx: ctx, + Obj: obj, + Sc: sc, } - mock.lockGetProgress.Lock() - mock.calls.GetProgress = append(mock.calls.GetProgress, callInfo) - mock.lockGetProgress.Unlock() - return mock.GetProgressFunc(dv, prevProgress, opts...) + mock.lockGetVolumeAndAccessModes.Lock() + mock.calls.GetVolumeAndAccessModes = append(mock.calls.GetVolumeAndAccessModes, callInfo) + mock.lockGetVolumeAndAccessModes.Unlock() + return mock.GetVolumeAndAccessModesFunc(ctx, obj, sc) } -// GetProgressCalls gets all the calls that were made to GetProgress. +// GetVolumeAndAccessModesCalls gets all the calls that were made to GetVolumeAndAccessModes. // Check the length with: // -// len(mockedObjectRefClusterVirtualImageDiskService.GetProgressCalls()) -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetProgressCalls() []struct { - Dv *cdiv1.DataVolume - PrevProgress string - Opts []service.GetProgressOption +// len(mockedObjectRefVirtualImageDiskService.GetVolumeAndAccessModesCalls()) +func (mock *ObjectRefVirtualImageDiskServiceMock) GetVolumeAndAccessModesCalls() []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass } { var calls []struct { - Dv *cdiv1.DataVolume - PrevProgress string - Opts []service.GetProgressOption + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass } - mock.lockGetProgress.RLock() - calls = mock.calls.GetProgress - mock.lockGetProgress.RUnlock() + mock.lockGetVolumeAndAccessModes.RLock() + calls = mock.calls.GetVolumeAndAccessModes + mock.lockGetVolumeAndAccessModes.RUnlock() return calls } -// Protect calls ProtectFunc. -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) Protect(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { - if mock.ProtectFunc == nil { - panic("ObjectRefClusterVirtualImageDiskServiceMock.ProtectFunc: method is nil but ObjectRefClusterVirtualImageDiskService.Protect was just called") - } - callInfo := struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim - }{ - Ctx: ctx, - Sup: sup, - Owner: owner, - Dv: dv, - Pvc: pvc, - } - mock.lockProtect.Lock() - mock.calls.Protect = append(mock.calls.Protect, callInfo) - mock.lockProtect.Unlock() - return mock.ProtectFunc(ctx, sup, owner, dv, pvc) -} +// Ensure, that ObjectRefVirtualImageStatServiceMock does implement ObjectRefVirtualImageStatService. +// If this is not the case, regenerate this file with moq. +var _ ObjectRefVirtualImageStatService = &ObjectRefVirtualImageStatServiceMock{} -// ProtectCalls gets all the calls that were made to Protect. -// Check the length with: +// ObjectRefVirtualImageStatServiceMock is a mock implementation of ObjectRefVirtualImageStatService. // -// len(mockedObjectRefClusterVirtualImageDiskService.ProtectCalls()) -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) ProtectCalls() []struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim -} { - var calls []struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim +// func TestSomethingThatUsesObjectRefVirtualImageStatService(t *testing.T) { +// +// // make and configure a mocked ObjectRefVirtualImageStatService +// mockedObjectRefVirtualImageStatService := &ObjectRefVirtualImageStatServiceMock{ +// GetProgressFunc: func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { +// panic("mock out the GetProgress method") +// }, +// } +// +// // use mockedObjectRefVirtualImageStatService in code that requires ObjectRefVirtualImageStatService +// // and then make assertions. +// +// } +type ObjectRefVirtualImageStatServiceMock struct { + // GetProgressFunc mocks the GetProgress method. + GetProgressFunc func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string + + // calls tracks calls to the methods. + calls struct { + // GetProgress holds details about calls to the GetProgress method. + GetProgress []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + // PrevProgress is the prevProgress argument value. + PrevProgress string + // Opts is the opts argument value. + Opts []service.GetProgressOption + } } - mock.lockProtect.RLock() - calls = mock.calls.Protect - mock.lockProtect.RUnlock() - return calls + lockGetProgress sync.RWMutex } -// Start calls StartFunc. -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) Start(ctx context.Context, pvcSize resource.Quantity, sc *storagev1.StorageClass, source *cdiv1.DataVolumeSource, obj client.Object, sup supplements.DataVolumeSupplement, opts ...service.Option) error { - if mock.StartFunc == nil { - panic("ObjectRefClusterVirtualImageDiskServiceMock.StartFunc: method is nil but ObjectRefClusterVirtualImageDiskService.Start was just called") +// GetProgress calls GetProgressFunc. +func (mock *ObjectRefVirtualImageStatServiceMock) GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { + if mock.GetProgressFunc == nil { + panic("ObjectRefVirtualImageStatServiceMock.GetProgressFunc: method is nil but ObjectRefVirtualImageStatService.GetProgress was just called") } callInfo := struct { - Ctx context.Context - PvcSize resource.Quantity - Sc *storagev1.StorageClass - Source *cdiv1.DataVolumeSource - Obj client.Object - Sup supplements.DataVolumeSupplement - Opts []service.Option + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption }{ - Ctx: ctx, - PvcSize: pvcSize, - Sc: sc, - Source: source, - Obj: obj, - Sup: sup, - Opts: opts, + OwnerUID: ownerUID, + Pod: pod, + PrevProgress: prevProgress, + Opts: opts, } - mock.lockStart.Lock() - mock.calls.Start = append(mock.calls.Start, callInfo) - mock.lockStart.Unlock() - return mock.StartFunc(ctx, pvcSize, sc, source, obj, sup, opts...) + mock.lockGetProgress.Lock() + mock.calls.GetProgress = append(mock.calls.GetProgress, callInfo) + mock.lockGetProgress.Unlock() + return mock.GetProgressFunc(ownerUID, pod, prevProgress, opts...) } -// StartCalls gets all the calls that were made to Start. +// GetProgressCalls gets all the calls that were made to GetProgress. // Check the length with: // -// len(mockedObjectRefClusterVirtualImageDiskService.StartCalls()) -func (mock *ObjectRefClusterVirtualImageDiskServiceMock) StartCalls() []struct { - Ctx context.Context - PvcSize resource.Quantity - Sc *storagev1.StorageClass - Source *cdiv1.DataVolumeSource - Obj client.Object - Sup supplements.DataVolumeSupplement - Opts []service.Option +// len(mockedObjectRefVirtualImageStatService.GetProgressCalls()) +func (mock *ObjectRefVirtualImageStatServiceMock) GetProgressCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption } { var calls []struct { - Ctx context.Context - PvcSize resource.Quantity - Sc *storagev1.StorageClass - Source *cdiv1.DataVolumeSource - Obj client.Object - Sup supplements.DataVolumeSupplement - Opts []service.Option + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption } - mock.lockStart.RLock() - calls = mock.calls.Start - mock.lockStart.RUnlock() + mock.lockGetProgress.RLock() + calls = mock.calls.GetProgress + mock.lockGetProgress.RUnlock() return calls } -// Ensure, that ObjectRefVirtualDiskSnapshotDiskServiceMock does implement ObjectRefVirtualDiskSnapshotDiskService. +// Ensure, that ObjectRefClusterVirtualImageDiskServiceMock does implement ObjectRefClusterVirtualImageDiskService. // If this is not the case, regenerate this file with moq. -var _ ObjectRefVirtualDiskSnapshotDiskService = &ObjectRefVirtualDiskSnapshotDiskServiceMock{} +var _ ObjectRefClusterVirtualImageDiskService = &ObjectRefClusterVirtualImageDiskServiceMock{} -// ObjectRefVirtualDiskSnapshotDiskServiceMock is a mock implementation of ObjectRefVirtualDiskSnapshotDiskService. +// ObjectRefClusterVirtualImageDiskServiceMock is a mock implementation of ObjectRefClusterVirtualImageDiskService. // -// func TestSomethingThatUsesObjectRefVirtualDiskSnapshotDiskService(t *testing.T) { +// func TestSomethingThatUsesObjectRefClusterVirtualImageDiskService(t *testing.T) { // -// // make and configure a mocked ObjectRefVirtualDiskSnapshotDiskService -// mockedObjectRefVirtualDiskSnapshotDiskService := &ObjectRefVirtualDiskSnapshotDiskServiceMock{ +// // make and configure a mocked ObjectRefClusterVirtualImageDiskService +// mockedObjectRefClusterVirtualImageDiskService := &ObjectRefClusterVirtualImageDiskServiceMock{ // CleanUpSupplementsFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { // panic("mock out the CleanUpSupplements method") // }, // GetCapacityFunc: func(pvc *corev1.PersistentVolumeClaim) string { // panic("mock out the GetCapacity method") // }, -// ProtectFunc: func(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { -// panic("mock out the Protect method") +// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +// panic("mock out the GetVolumeAndAccessModes method") // }, // } // -// // use mockedObjectRefVirtualDiskSnapshotDiskService in code that requires ObjectRefVirtualDiskSnapshotDiskService +// // use mockedObjectRefClusterVirtualImageDiskService in code that requires ObjectRefClusterVirtualImageDiskService // // and then make assertions. // // } -type ObjectRefVirtualDiskSnapshotDiskServiceMock struct { +type ObjectRefClusterVirtualImageDiskServiceMock struct { // CleanUpSupplementsFunc mocks the CleanUpSupplements method. CleanUpSupplementsFunc func(ctx context.Context, sup supplements.Generator) (bool, error) // GetCapacityFunc mocks the GetCapacity method. GetCapacityFunc func(pvc *corev1.PersistentVolumeClaim) string - // ProtectFunc mocks the Protect method. - ProtectFunc func(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error + // GetVolumeAndAccessModesFunc mocks the GetVolumeAndAccessModes method. + GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) // calls tracks calls to the methods. calls struct { @@ -1405,29 +1305,25 @@ type ObjectRefVirtualDiskSnapshotDiskServiceMock struct { // Pvc is the pvc argument value. Pvc *corev1.PersistentVolumeClaim } - // Protect holds details about calls to the Protect method. - Protect []struct { + // GetVolumeAndAccessModes holds details about calls to the GetVolumeAndAccessModes method. + GetVolumeAndAccessModes []struct { // Ctx is the ctx argument value. Ctx context.Context - // Sup is the sup argument value. - Sup supplements.Generator - // Owner is the owner argument value. - Owner client.Object - // Dv is the dv argument value. - Dv *cdiv1.DataVolume - // Pvc is the pvc argument value. - Pvc *corev1.PersistentVolumeClaim + // Obj is the obj argument value. + Obj client.Object + // Sc is the sc argument value. + Sc *v1.StorageClass } } - lockCleanUpSupplements sync.RWMutex - lockGetCapacity sync.RWMutex - lockProtect sync.RWMutex + lockCleanUpSupplements sync.RWMutex + lockGetCapacity sync.RWMutex + lockGetVolumeAndAccessModes sync.RWMutex } // CleanUpSupplements calls CleanUpSupplementsFunc. -func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { +func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { if mock.CleanUpSupplementsFunc == nil { - panic("ObjectRefVirtualDiskSnapshotDiskServiceMock.CleanUpSupplementsFunc: method is nil but ObjectRefVirtualDiskSnapshotDiskService.CleanUpSupplements was just called") + panic("ObjectRefClusterVirtualImageDiskServiceMock.CleanUpSupplementsFunc: method is nil but ObjectRefClusterVirtualImageDiskService.CleanUpSupplements was just called") } callInfo := struct { Ctx context.Context @@ -1445,9 +1341,265 @@ func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) CleanUpSupplements(ctx // CleanUpSupplementsCalls gets all the calls that were made to CleanUpSupplements. // Check the length with: // -// len(mockedObjectRefVirtualDiskSnapshotDiskService.CleanUpSupplementsCalls()) -func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) CleanUpSupplementsCalls() []struct { - Ctx context.Context +// len(mockedObjectRefClusterVirtualImageDiskService.CleanUpSupplementsCalls()) +func (mock *ObjectRefClusterVirtualImageDiskServiceMock) CleanUpSupplementsCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUpSupplements.RLock() + calls = mock.calls.CleanUpSupplements + mock.lockCleanUpSupplements.RUnlock() + return calls +} + +// GetCapacity calls GetCapacityFunc. +func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetCapacity(pvc *corev1.PersistentVolumeClaim) string { + if mock.GetCapacityFunc == nil { + panic("ObjectRefClusterVirtualImageDiskServiceMock.GetCapacityFunc: method is nil but ObjectRefClusterVirtualImageDiskService.GetCapacity was just called") + } + callInfo := struct { + Pvc *corev1.PersistentVolumeClaim + }{ + Pvc: pvc, + } + mock.lockGetCapacity.Lock() + mock.calls.GetCapacity = append(mock.calls.GetCapacity, callInfo) + mock.lockGetCapacity.Unlock() + return mock.GetCapacityFunc(pvc) +} + +// GetCapacityCalls gets all the calls that were made to GetCapacity. +// Check the length with: +// +// len(mockedObjectRefClusterVirtualImageDiskService.GetCapacityCalls()) +func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetCapacityCalls() []struct { + Pvc *corev1.PersistentVolumeClaim +} { + var calls []struct { + Pvc *corev1.PersistentVolumeClaim + } + mock.lockGetCapacity.RLock() + calls = mock.calls.GetCapacity + mock.lockGetCapacity.RUnlock() + return calls +} + +// GetVolumeAndAccessModes calls GetVolumeAndAccessModesFunc. +func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + if mock.GetVolumeAndAccessModesFunc == nil { + panic("ObjectRefClusterVirtualImageDiskServiceMock.GetVolumeAndAccessModesFunc: method is nil but ObjectRefClusterVirtualImageDiskService.GetVolumeAndAccessModes was just called") + } + callInfo := struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass + }{ + Ctx: ctx, + Obj: obj, + Sc: sc, + } + mock.lockGetVolumeAndAccessModes.Lock() + mock.calls.GetVolumeAndAccessModes = append(mock.calls.GetVolumeAndAccessModes, callInfo) + mock.lockGetVolumeAndAccessModes.Unlock() + return mock.GetVolumeAndAccessModesFunc(ctx, obj, sc) +} + +// GetVolumeAndAccessModesCalls gets all the calls that were made to GetVolumeAndAccessModes. +// Check the length with: +// +// len(mockedObjectRefClusterVirtualImageDiskService.GetVolumeAndAccessModesCalls()) +func (mock *ObjectRefClusterVirtualImageDiskServiceMock) GetVolumeAndAccessModesCalls() []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass +} { + var calls []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass + } + mock.lockGetVolumeAndAccessModes.RLock() + calls = mock.calls.GetVolumeAndAccessModes + mock.lockGetVolumeAndAccessModes.RUnlock() + return calls +} + +// Ensure, that ObjectRefClusterVirtualImageStatServiceMock does implement ObjectRefClusterVirtualImageStatService. +// If this is not the case, regenerate this file with moq. +var _ ObjectRefClusterVirtualImageStatService = &ObjectRefClusterVirtualImageStatServiceMock{} + +// ObjectRefClusterVirtualImageStatServiceMock is a mock implementation of ObjectRefClusterVirtualImageStatService. +// +// func TestSomethingThatUsesObjectRefClusterVirtualImageStatService(t *testing.T) { +// +// // make and configure a mocked ObjectRefClusterVirtualImageStatService +// mockedObjectRefClusterVirtualImageStatService := &ObjectRefClusterVirtualImageStatServiceMock{ +// GetProgressFunc: func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { +// panic("mock out the GetProgress method") +// }, +// } +// +// // use mockedObjectRefClusterVirtualImageStatService in code that requires ObjectRefClusterVirtualImageStatService +// // and then make assertions. +// +// } +type ObjectRefClusterVirtualImageStatServiceMock struct { + // GetProgressFunc mocks the GetProgress method. + GetProgressFunc func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string + + // calls tracks calls to the methods. + calls struct { + // GetProgress holds details about calls to the GetProgress method. + GetProgress []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + // PrevProgress is the prevProgress argument value. + PrevProgress string + // Opts is the opts argument value. + Opts []service.GetProgressOption + } + } + lockGetProgress sync.RWMutex +} + +// GetProgress calls GetProgressFunc. +func (mock *ObjectRefClusterVirtualImageStatServiceMock) GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { + if mock.GetProgressFunc == nil { + panic("ObjectRefClusterVirtualImageStatServiceMock.GetProgressFunc: method is nil but ObjectRefClusterVirtualImageStatService.GetProgress was just called") + } + callInfo := struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption + }{ + OwnerUID: ownerUID, + Pod: pod, + PrevProgress: prevProgress, + Opts: opts, + } + mock.lockGetProgress.Lock() + mock.calls.GetProgress = append(mock.calls.GetProgress, callInfo) + mock.lockGetProgress.Unlock() + return mock.GetProgressFunc(ownerUID, pod, prevProgress, opts...) +} + +// GetProgressCalls gets all the calls that were made to GetProgress. +// Check the length with: +// +// len(mockedObjectRefClusterVirtualImageStatService.GetProgressCalls()) +func (mock *ObjectRefClusterVirtualImageStatServiceMock) GetProgressCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption +} { + var calls []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption + } + mock.lockGetProgress.RLock() + calls = mock.calls.GetProgress + mock.lockGetProgress.RUnlock() + return calls +} + +// Ensure, that ObjectRefVirtualDiskSnapshotDiskServiceMock does implement ObjectRefVirtualDiskSnapshotDiskService. +// If this is not the case, regenerate this file with moq. +var _ ObjectRefVirtualDiskSnapshotDiskService = &ObjectRefVirtualDiskSnapshotDiskServiceMock{} + +// ObjectRefVirtualDiskSnapshotDiskServiceMock is a mock implementation of ObjectRefVirtualDiskSnapshotDiskService. +// +// func TestSomethingThatUsesObjectRefVirtualDiskSnapshotDiskService(t *testing.T) { +// +// // make and configure a mocked ObjectRefVirtualDiskSnapshotDiskService +// mockedObjectRefVirtualDiskSnapshotDiskService := &ObjectRefVirtualDiskSnapshotDiskServiceMock{ +// CleanUpSupplementsFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUpSupplements method") +// }, +// GetCapacityFunc: func(pvc *corev1.PersistentVolumeClaim) string { +// panic("mock out the GetCapacity method") +// }, +// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +// panic("mock out the GetVolumeAndAccessModes method") +// }, +// } +// +// // use mockedObjectRefVirtualDiskSnapshotDiskService in code that requires ObjectRefVirtualDiskSnapshotDiskService +// // and then make assertions. +// +// } +type ObjectRefVirtualDiskSnapshotDiskServiceMock struct { + // CleanUpSupplementsFunc mocks the CleanUpSupplements method. + CleanUpSupplementsFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // GetCapacityFunc mocks the GetCapacity method. + GetCapacityFunc func(pvc *corev1.PersistentVolumeClaim) string + + // GetVolumeAndAccessModesFunc mocks the GetVolumeAndAccessModes method. + GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) + + // calls tracks calls to the methods. + calls struct { + // CleanUpSupplements holds details about calls to the CleanUpSupplements method. + CleanUpSupplements []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetCapacity holds details about calls to the GetCapacity method. + GetCapacity []struct { + // Pvc is the pvc argument value. + Pvc *corev1.PersistentVolumeClaim + } + // GetVolumeAndAccessModes holds details about calls to the GetVolumeAndAccessModes method. + GetVolumeAndAccessModes []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Obj is the obj argument value. + Obj client.Object + // Sc is the sc argument value. + Sc *v1.StorageClass + } + } + lockCleanUpSupplements sync.RWMutex + lockGetCapacity sync.RWMutex + lockGetVolumeAndAccessModes sync.RWMutex +} + +// CleanUpSupplements calls CleanUpSupplementsFunc. +func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpSupplementsFunc == nil { + panic("ObjectRefVirtualDiskSnapshotDiskServiceMock.CleanUpSupplementsFunc: method is nil but ObjectRefVirtualDiskSnapshotDiskService.CleanUpSupplements was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUpSupplements.Lock() + mock.calls.CleanUpSupplements = append(mock.calls.CleanUpSupplements, callInfo) + mock.lockCleanUpSupplements.Unlock() + return mock.CleanUpSupplementsFunc(ctx, sup) +} + +// CleanUpSupplementsCalls gets all the calls that were made to CleanUpSupplements. +// Check the length with: +// +// len(mockedObjectRefVirtualDiskSnapshotDiskService.CleanUpSupplementsCalls()) +func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) CleanUpSupplementsCalls() []struct { + Ctx context.Context Sup supplements.Generator } { var calls []struct { @@ -1492,50 +1644,2800 @@ func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) GetCapacityCalls() []st return calls } -// Protect calls ProtectFunc. -func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) Protect(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error { - if mock.ProtectFunc == nil { - panic("ObjectRefVirtualDiskSnapshotDiskServiceMock.ProtectFunc: method is nil but ObjectRefVirtualDiskSnapshotDiskService.Protect was just called") +// GetVolumeAndAccessModes calls GetVolumeAndAccessModesFunc. +func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + if mock.GetVolumeAndAccessModesFunc == nil { + panic("ObjectRefVirtualDiskSnapshotDiskServiceMock.GetVolumeAndAccessModesFunc: method is nil but ObjectRefVirtualDiskSnapshotDiskService.GetVolumeAndAccessModes was just called") } callInfo := struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass }{ - Ctx: ctx, - Sup: sup, - Owner: owner, - Dv: dv, - Pvc: pvc, + Ctx: ctx, + Obj: obj, + Sc: sc, } - mock.lockProtect.Lock() - mock.calls.Protect = append(mock.calls.Protect, callInfo) - mock.lockProtect.Unlock() - return mock.ProtectFunc(ctx, sup, owner, dv, pvc) + mock.lockGetVolumeAndAccessModes.Lock() + mock.calls.GetVolumeAndAccessModes = append(mock.calls.GetVolumeAndAccessModes, callInfo) + mock.lockGetVolumeAndAccessModes.Unlock() + return mock.GetVolumeAndAccessModesFunc(ctx, obj, sc) } -// ProtectCalls gets all the calls that were made to Protect. +// GetVolumeAndAccessModesCalls gets all the calls that were made to GetVolumeAndAccessModes. // Check the length with: // -// len(mockedObjectRefVirtualDiskSnapshotDiskService.ProtectCalls()) -func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) ProtectCalls() []struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim +// len(mockedObjectRefVirtualDiskSnapshotDiskService.GetVolumeAndAccessModesCalls()) +func (mock *ObjectRefVirtualDiskSnapshotDiskServiceMock) GetVolumeAndAccessModesCalls() []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass } { var calls []struct { - Ctx context.Context - Sup supplements.Generator - Owner client.Object - Dv *cdiv1.DataVolume - Pvc *corev1.PersistentVolumeClaim + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass } - mock.lockProtect.RLock() - calls = mock.calls.Protect - mock.lockProtect.RUnlock() + mock.lockGetVolumeAndAccessModes.RLock() + calls = mock.calls.GetVolumeAndAccessModes + mock.lockGetVolumeAndAccessModes.RUnlock() + return calls +} + +// Ensure, that UploadDataSourceDiskServiceMock does implement UploadDataSourceDiskService. +// If this is not the case, regenerate this file with moq. +var _ UploadDataSourceDiskService = &UploadDataSourceDiskServiceMock{} + +// UploadDataSourceDiskServiceMock is a mock implementation of UploadDataSourceDiskService. +// +// func TestSomethingThatUsesUploadDataSourceDiskService(t *testing.T) { +// +// // make and configure a mocked UploadDataSourceDiskService +// mockedUploadDataSourceDiskService := &UploadDataSourceDiskServiceMock{ +// CleanUpFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUp method") +// }, +// CleanUpSupplementsFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUpSupplements method") +// }, +// GetCapacityFunc: func(pvc *corev1.PersistentVolumeClaim) string { +// panic("mock out the GetCapacity method") +// }, +// GetPersistentVolumeClaimFunc: func(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) { +// panic("mock out the GetPersistentVolumeClaim method") +// }, +// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +// panic("mock out the GetVolumeAndAccessModes method") +// }, +// } +// +// // use mockedUploadDataSourceDiskService in code that requires UploadDataSourceDiskService +// // and then make assertions. +// +// } +type UploadDataSourceDiskServiceMock struct { + // CleanUpFunc mocks the CleanUp method. + CleanUpFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // CleanUpSupplementsFunc mocks the CleanUpSupplements method. + CleanUpSupplementsFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // GetCapacityFunc mocks the GetCapacity method. + GetCapacityFunc func(pvc *corev1.PersistentVolumeClaim) string + + // GetPersistentVolumeClaimFunc mocks the GetPersistentVolumeClaim method. + GetPersistentVolumeClaimFunc func(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) + + // GetVolumeAndAccessModesFunc mocks the GetVolumeAndAccessModes method. + GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) + + // calls tracks calls to the methods. + calls struct { + // CleanUp holds details about calls to the CleanUp method. + CleanUp []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // CleanUpSupplements holds details about calls to the CleanUpSupplements method. + CleanUpSupplements []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetCapacity holds details about calls to the GetCapacity method. + GetCapacity []struct { + // Pvc is the pvc argument value. + Pvc *corev1.PersistentVolumeClaim + } + // GetPersistentVolumeClaim holds details about calls to the GetPersistentVolumeClaim method. + GetPersistentVolumeClaim []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetVolumeAndAccessModes holds details about calls to the GetVolumeAndAccessModes method. + GetVolumeAndAccessModes []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Obj is the obj argument value. + Obj client.Object + // Sc is the sc argument value. + Sc *v1.StorageClass + } + } + lockCleanUp sync.RWMutex + lockCleanUpSupplements sync.RWMutex + lockGetCapacity sync.RWMutex + lockGetPersistentVolumeClaim sync.RWMutex + lockGetVolumeAndAccessModes sync.RWMutex +} + +// CleanUp calls CleanUpFunc. +func (mock *UploadDataSourceDiskServiceMock) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpFunc == nil { + panic("UploadDataSourceDiskServiceMock.CleanUpFunc: method is nil but UploadDataSourceDiskService.CleanUp was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUp.Lock() + mock.calls.CleanUp = append(mock.calls.CleanUp, callInfo) + mock.lockCleanUp.Unlock() + return mock.CleanUpFunc(ctx, sup) +} + +// CleanUpCalls gets all the calls that were made to CleanUp. +// Check the length with: +// +// len(mockedUploadDataSourceDiskService.CleanUpCalls()) +func (mock *UploadDataSourceDiskServiceMock) CleanUpCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUp.RLock() + calls = mock.calls.CleanUp + mock.lockCleanUp.RUnlock() + return calls +} + +// CleanUpSupplements calls CleanUpSupplementsFunc. +func (mock *UploadDataSourceDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpSupplementsFunc == nil { + panic("UploadDataSourceDiskServiceMock.CleanUpSupplementsFunc: method is nil but UploadDataSourceDiskService.CleanUpSupplements was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUpSupplements.Lock() + mock.calls.CleanUpSupplements = append(mock.calls.CleanUpSupplements, callInfo) + mock.lockCleanUpSupplements.Unlock() + return mock.CleanUpSupplementsFunc(ctx, sup) +} + +// CleanUpSupplementsCalls gets all the calls that were made to CleanUpSupplements. +// Check the length with: +// +// len(mockedUploadDataSourceDiskService.CleanUpSupplementsCalls()) +func (mock *UploadDataSourceDiskServiceMock) CleanUpSupplementsCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUpSupplements.RLock() + calls = mock.calls.CleanUpSupplements + mock.lockCleanUpSupplements.RUnlock() + return calls +} + +// GetCapacity calls GetCapacityFunc. +func (mock *UploadDataSourceDiskServiceMock) GetCapacity(pvc *corev1.PersistentVolumeClaim) string { + if mock.GetCapacityFunc == nil { + panic("UploadDataSourceDiskServiceMock.GetCapacityFunc: method is nil but UploadDataSourceDiskService.GetCapacity was just called") + } + callInfo := struct { + Pvc *corev1.PersistentVolumeClaim + }{ + Pvc: pvc, + } + mock.lockGetCapacity.Lock() + mock.calls.GetCapacity = append(mock.calls.GetCapacity, callInfo) + mock.lockGetCapacity.Unlock() + return mock.GetCapacityFunc(pvc) +} + +// GetCapacityCalls gets all the calls that were made to GetCapacity. +// Check the length with: +// +// len(mockedUploadDataSourceDiskService.GetCapacityCalls()) +func (mock *UploadDataSourceDiskServiceMock) GetCapacityCalls() []struct { + Pvc *corev1.PersistentVolumeClaim +} { + var calls []struct { + Pvc *corev1.PersistentVolumeClaim + } + mock.lockGetCapacity.RLock() + calls = mock.calls.GetCapacity + mock.lockGetCapacity.RUnlock() + return calls +} + +// GetPersistentVolumeClaim calls GetPersistentVolumeClaimFunc. +func (mock *UploadDataSourceDiskServiceMock) GetPersistentVolumeClaim(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + if mock.GetPersistentVolumeClaimFunc == nil { + panic("UploadDataSourceDiskServiceMock.GetPersistentVolumeClaimFunc: method is nil but UploadDataSourceDiskService.GetPersistentVolumeClaim was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockGetPersistentVolumeClaim.Lock() + mock.calls.GetPersistentVolumeClaim = append(mock.calls.GetPersistentVolumeClaim, callInfo) + mock.lockGetPersistentVolumeClaim.Unlock() + return mock.GetPersistentVolumeClaimFunc(ctx, sup) +} + +// GetPersistentVolumeClaimCalls gets all the calls that were made to GetPersistentVolumeClaim. +// Check the length with: +// +// len(mockedUploadDataSourceDiskService.GetPersistentVolumeClaimCalls()) +func (mock *UploadDataSourceDiskServiceMock) GetPersistentVolumeClaimCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockGetPersistentVolumeClaim.RLock() + calls = mock.calls.GetPersistentVolumeClaim + mock.lockGetPersistentVolumeClaim.RUnlock() + return calls +} + +// GetVolumeAndAccessModes calls GetVolumeAndAccessModesFunc. +func (mock *UploadDataSourceDiskServiceMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + if mock.GetVolumeAndAccessModesFunc == nil { + panic("UploadDataSourceDiskServiceMock.GetVolumeAndAccessModesFunc: method is nil but UploadDataSourceDiskService.GetVolumeAndAccessModes was just called") + } + callInfo := struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass + }{ + Ctx: ctx, + Obj: obj, + Sc: sc, + } + mock.lockGetVolumeAndAccessModes.Lock() + mock.calls.GetVolumeAndAccessModes = append(mock.calls.GetVolumeAndAccessModes, callInfo) + mock.lockGetVolumeAndAccessModes.Unlock() + return mock.GetVolumeAndAccessModesFunc(ctx, obj, sc) +} + +// GetVolumeAndAccessModesCalls gets all the calls that were made to GetVolumeAndAccessModes. +// Check the length with: +// +// len(mockedUploadDataSourceDiskService.GetVolumeAndAccessModesCalls()) +func (mock *UploadDataSourceDiskServiceMock) GetVolumeAndAccessModesCalls() []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass +} { + var calls []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass + } + mock.lockGetVolumeAndAccessModes.RLock() + calls = mock.calls.GetVolumeAndAccessModes + mock.lockGetVolumeAndAccessModes.RUnlock() + return calls +} + +// Ensure, that UploadDataSourceUploaderServiceMock does implement UploadDataSourceUploaderService. +// If this is not the case, regenerate this file with moq. +var _ UploadDataSourceUploaderService = &UploadDataSourceUploaderServiceMock{} + +// UploadDataSourceUploaderServiceMock is a mock implementation of UploadDataSourceUploaderService. +// +// func TestSomethingThatUsesUploadDataSourceUploaderService(t *testing.T) { +// +// // make and configure a mocked UploadDataSourceUploaderService +// mockedUploadDataSourceUploaderService := &UploadDataSourceUploaderServiceMock{ +// CleanUpFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUp method") +// }, +// GetExternalURLFunc: func(ctx context.Context, ing *netv1.Ingress) string { +// panic("mock out the GetExternalURL method") +// }, +// GetInClusterURLFunc: func(ctx context.Context, svc *corev1.Service) string { +// panic("mock out the GetInClusterURL method") +// }, +// GetIngressFunc: func(ctx context.Context, sup supplements.Generator) (*netv1.Ingress, error) { +// panic("mock out the GetIngress method") +// }, +// GetPodFunc: func(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) { +// panic("mock out the GetPod method") +// }, +// GetServiceFunc: func(ctx context.Context, sup supplements.Generator) (*corev1.Service, error) { +// panic("mock out the GetService method") +// }, +// StartFunc: func(ctx context.Context, settings *uploader.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error { +// panic("mock out the Start method") +// }, +// } +// +// // use mockedUploadDataSourceUploaderService in code that requires UploadDataSourceUploaderService +// // and then make assertions. +// +// } +type UploadDataSourceUploaderServiceMock struct { + // CleanUpFunc mocks the CleanUp method. + CleanUpFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // GetExternalURLFunc mocks the GetExternalURL method. + GetExternalURLFunc func(ctx context.Context, ing *netv1.Ingress) string + + // GetInClusterURLFunc mocks the GetInClusterURL method. + GetInClusterURLFunc func(ctx context.Context, svc *corev1.Service) string + + // GetIngressFunc mocks the GetIngress method. + GetIngressFunc func(ctx context.Context, sup supplements.Generator) (*netv1.Ingress, error) + + // GetPodFunc mocks the GetPod method. + GetPodFunc func(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) + + // GetServiceFunc mocks the GetService method. + GetServiceFunc func(ctx context.Context, sup supplements.Generator) (*corev1.Service, error) + + // StartFunc mocks the Start method. + StartFunc func(ctx context.Context, settings *uploader.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error + + // calls tracks calls to the methods. + calls struct { + // CleanUp holds details about calls to the CleanUp method. + CleanUp []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetExternalURL holds details about calls to the GetExternalURL method. + GetExternalURL []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Ing is the ing argument value. + Ing *netv1.Ingress + } + // GetInClusterURL holds details about calls to the GetInClusterURL method. + GetInClusterURL []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Svc is the svc argument value. + Svc *corev1.Service + } + // GetIngress holds details about calls to the GetIngress method. + GetIngress []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetPod holds details about calls to the GetPod method. + GetPod []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetService holds details about calls to the GetService method. + GetService []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // Start holds details about calls to the Start method. + Start []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Settings is the settings argument value. + Settings *uploader.Settings + // Obj is the obj argument value. + Obj client.Object + // Sup is the sup argument value. + Sup supplements.Generator + // CaBundle is the caBundle argument value. + CaBundle *datasource.CABundle + // Opts is the opts argument value. + Opts []service.Option + } + } + lockCleanUp sync.RWMutex + lockGetExternalURL sync.RWMutex + lockGetInClusterURL sync.RWMutex + lockGetIngress sync.RWMutex + lockGetPod sync.RWMutex + lockGetService sync.RWMutex + lockStart sync.RWMutex +} + +// CleanUp calls CleanUpFunc. +func (mock *UploadDataSourceUploaderServiceMock) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpFunc == nil { + panic("UploadDataSourceUploaderServiceMock.CleanUpFunc: method is nil but UploadDataSourceUploaderService.CleanUp was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUp.Lock() + mock.calls.CleanUp = append(mock.calls.CleanUp, callInfo) + mock.lockCleanUp.Unlock() + return mock.CleanUpFunc(ctx, sup) +} + +// CleanUpCalls gets all the calls that were made to CleanUp. +// Check the length with: +// +// len(mockedUploadDataSourceUploaderService.CleanUpCalls()) +func (mock *UploadDataSourceUploaderServiceMock) CleanUpCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUp.RLock() + calls = mock.calls.CleanUp + mock.lockCleanUp.RUnlock() + return calls +} + +// GetExternalURL calls GetExternalURLFunc. +func (mock *UploadDataSourceUploaderServiceMock) GetExternalURL(ctx context.Context, ing *netv1.Ingress) string { + if mock.GetExternalURLFunc == nil { + panic("UploadDataSourceUploaderServiceMock.GetExternalURLFunc: method is nil but UploadDataSourceUploaderService.GetExternalURL was just called") + } + callInfo := struct { + Ctx context.Context + Ing *netv1.Ingress + }{ + Ctx: ctx, + Ing: ing, + } + mock.lockGetExternalURL.Lock() + mock.calls.GetExternalURL = append(mock.calls.GetExternalURL, callInfo) + mock.lockGetExternalURL.Unlock() + return mock.GetExternalURLFunc(ctx, ing) +} + +// GetExternalURLCalls gets all the calls that were made to GetExternalURL. +// Check the length with: +// +// len(mockedUploadDataSourceUploaderService.GetExternalURLCalls()) +func (mock *UploadDataSourceUploaderServiceMock) GetExternalURLCalls() []struct { + Ctx context.Context + Ing *netv1.Ingress +} { + var calls []struct { + Ctx context.Context + Ing *netv1.Ingress + } + mock.lockGetExternalURL.RLock() + calls = mock.calls.GetExternalURL + mock.lockGetExternalURL.RUnlock() + return calls +} + +// GetInClusterURL calls GetInClusterURLFunc. +func (mock *UploadDataSourceUploaderServiceMock) GetInClusterURL(ctx context.Context, svc *corev1.Service) string { + if mock.GetInClusterURLFunc == nil { + panic("UploadDataSourceUploaderServiceMock.GetInClusterURLFunc: method is nil but UploadDataSourceUploaderService.GetInClusterURL was just called") + } + callInfo := struct { + Ctx context.Context + Svc *corev1.Service + }{ + Ctx: ctx, + Svc: svc, + } + mock.lockGetInClusterURL.Lock() + mock.calls.GetInClusterURL = append(mock.calls.GetInClusterURL, callInfo) + mock.lockGetInClusterURL.Unlock() + return mock.GetInClusterURLFunc(ctx, svc) +} + +// GetInClusterURLCalls gets all the calls that were made to GetInClusterURL. +// Check the length with: +// +// len(mockedUploadDataSourceUploaderService.GetInClusterURLCalls()) +func (mock *UploadDataSourceUploaderServiceMock) GetInClusterURLCalls() []struct { + Ctx context.Context + Svc *corev1.Service +} { + var calls []struct { + Ctx context.Context + Svc *corev1.Service + } + mock.lockGetInClusterURL.RLock() + calls = mock.calls.GetInClusterURL + mock.lockGetInClusterURL.RUnlock() + return calls +} + +// GetIngress calls GetIngressFunc. +func (mock *UploadDataSourceUploaderServiceMock) GetIngress(ctx context.Context, sup supplements.Generator) (*netv1.Ingress, error) { + if mock.GetIngressFunc == nil { + panic("UploadDataSourceUploaderServiceMock.GetIngressFunc: method is nil but UploadDataSourceUploaderService.GetIngress was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockGetIngress.Lock() + mock.calls.GetIngress = append(mock.calls.GetIngress, callInfo) + mock.lockGetIngress.Unlock() + return mock.GetIngressFunc(ctx, sup) +} + +// GetIngressCalls gets all the calls that were made to GetIngress. +// Check the length with: +// +// len(mockedUploadDataSourceUploaderService.GetIngressCalls()) +func (mock *UploadDataSourceUploaderServiceMock) GetIngressCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockGetIngress.RLock() + calls = mock.calls.GetIngress + mock.lockGetIngress.RUnlock() + return calls +} + +// GetPod calls GetPodFunc. +func (mock *UploadDataSourceUploaderServiceMock) GetPod(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) { + if mock.GetPodFunc == nil { + panic("UploadDataSourceUploaderServiceMock.GetPodFunc: method is nil but UploadDataSourceUploaderService.GetPod was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockGetPod.Lock() + mock.calls.GetPod = append(mock.calls.GetPod, callInfo) + mock.lockGetPod.Unlock() + return mock.GetPodFunc(ctx, sup) +} + +// GetPodCalls gets all the calls that were made to GetPod. +// Check the length with: +// +// len(mockedUploadDataSourceUploaderService.GetPodCalls()) +func (mock *UploadDataSourceUploaderServiceMock) GetPodCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockGetPod.RLock() + calls = mock.calls.GetPod + mock.lockGetPod.RUnlock() + return calls +} + +// GetService calls GetServiceFunc. +func (mock *UploadDataSourceUploaderServiceMock) GetService(ctx context.Context, sup supplements.Generator) (*corev1.Service, error) { + if mock.GetServiceFunc == nil { + panic("UploadDataSourceUploaderServiceMock.GetServiceFunc: method is nil but UploadDataSourceUploaderService.GetService was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockGetService.Lock() + mock.calls.GetService = append(mock.calls.GetService, callInfo) + mock.lockGetService.Unlock() + return mock.GetServiceFunc(ctx, sup) +} + +// GetServiceCalls gets all the calls that were made to GetService. +// Check the length with: +// +// len(mockedUploadDataSourceUploaderService.GetServiceCalls()) +func (mock *UploadDataSourceUploaderServiceMock) GetServiceCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockGetService.RLock() + calls = mock.calls.GetService + mock.lockGetService.RUnlock() + return calls +} + +// Start calls StartFunc. +func (mock *UploadDataSourceUploaderServiceMock) Start(ctx context.Context, settings *uploader.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error { + if mock.StartFunc == nil { + panic("UploadDataSourceUploaderServiceMock.StartFunc: method is nil but UploadDataSourceUploaderService.Start was just called") + } + callInfo := struct { + Ctx context.Context + Settings *uploader.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option + }{ + Ctx: ctx, + Settings: settings, + Obj: obj, + Sup: sup, + CaBundle: caBundle, + Opts: opts, + } + mock.lockStart.Lock() + mock.calls.Start = append(mock.calls.Start, callInfo) + mock.lockStart.Unlock() + return mock.StartFunc(ctx, settings, obj, sup, caBundle, opts...) +} + +// StartCalls gets all the calls that were made to Start. +// Check the length with: +// +// len(mockedUploadDataSourceUploaderService.StartCalls()) +func (mock *UploadDataSourceUploaderServiceMock) StartCalls() []struct { + Ctx context.Context + Settings *uploader.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option +} { + var calls []struct { + Ctx context.Context + Settings *uploader.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option + } + mock.lockStart.RLock() + calls = mock.calls.Start + mock.lockStart.RUnlock() + return calls +} + +// Ensure, that UploadDataSourceStatServiceMock does implement UploadDataSourceStatService. +// If this is not the case, regenerate this file with moq. +var _ UploadDataSourceStatService = &UploadDataSourceStatServiceMock{} + +// UploadDataSourceStatServiceMock is a mock implementation of UploadDataSourceStatService. +// +// func TestSomethingThatUsesUploadDataSourceStatService(t *testing.T) { +// +// // make and configure a mocked UploadDataSourceStatService +// mockedUploadDataSourceStatService := &UploadDataSourceStatServiceMock{ +// CheckPodFunc: func(pod *corev1.Pod) error { +// panic("mock out the CheckPod method") +// }, +// GetDVCRImageNameFunc: func(pod *corev1.Pod) string { +// panic("mock out the GetDVCRImageName method") +// }, +// GetDownloadSpeedFunc: func(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed { +// panic("mock out the GetDownloadSpeed method") +// }, +// GetFormatFunc: func(pod *corev1.Pod) string { +// panic("mock out the GetFormat method") +// }, +// GetProgressFunc: func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { +// panic("mock out the GetProgress method") +// }, +// GetSizeFunc: func(pod *corev1.Pod) v1alpha2.ImageStatusSize { +// panic("mock out the GetSize method") +// }, +// IsUploadStartedFunc: func(ownerUID types.UID, pod *corev1.Pod) bool { +// panic("mock out the IsUploadStarted method") +// }, +// IsUploaderReadyFunc: func(pod *corev1.Pod, svc *corev1.Service, ing *netv1.Ingress, tlsSecret *corev1.Secret) (bool, error) { +// panic("mock out the IsUploaderReady method") +// }, +// } +// +// // use mockedUploadDataSourceStatService in code that requires UploadDataSourceStatService +// // and then make assertions. +// +// } +type UploadDataSourceStatServiceMock struct { + // CheckPodFunc mocks the CheckPod method. + CheckPodFunc func(pod *corev1.Pod) error + + // GetDVCRImageNameFunc mocks the GetDVCRImageName method. + GetDVCRImageNameFunc func(pod *corev1.Pod) string + + // GetDownloadSpeedFunc mocks the GetDownloadSpeed method. + GetDownloadSpeedFunc func(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed + + // GetFormatFunc mocks the GetFormat method. + GetFormatFunc func(pod *corev1.Pod) string + + // GetProgressFunc mocks the GetProgress method. + GetProgressFunc func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string + + // GetSizeFunc mocks the GetSize method. + GetSizeFunc func(pod *corev1.Pod) v1alpha2.ImageStatusSize + + // IsUploadStartedFunc mocks the IsUploadStarted method. + IsUploadStartedFunc func(ownerUID types.UID, pod *corev1.Pod) bool + + // IsUploaderReadyFunc mocks the IsUploaderReady method. + IsUploaderReadyFunc func(pod *corev1.Pod, svc *corev1.Service, ing *netv1.Ingress, tlsSecret *corev1.Secret) (bool, error) + + // calls tracks calls to the methods. + calls struct { + // CheckPod holds details about calls to the CheckPod method. + CheckPod []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetDVCRImageName holds details about calls to the GetDVCRImageName method. + GetDVCRImageName []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetDownloadSpeed holds details about calls to the GetDownloadSpeed method. + GetDownloadSpeed []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetFormat holds details about calls to the GetFormat method. + GetFormat []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetProgress holds details about calls to the GetProgress method. + GetProgress []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + // PrevProgress is the prevProgress argument value. + PrevProgress string + // Opts is the opts argument value. + Opts []service.GetProgressOption + } + // GetSize holds details about calls to the GetSize method. + GetSize []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // IsUploadStarted holds details about calls to the IsUploadStarted method. + IsUploadStarted []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + } + // IsUploaderReady holds details about calls to the IsUploaderReady method. + IsUploaderReady []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + // Svc is the svc argument value. + Svc *corev1.Service + // Ing is the ing argument value. + Ing *netv1.Ingress + // TlsSecret is the tlsSecret argument value. + TlsSecret *corev1.Secret + } + } + lockCheckPod sync.RWMutex + lockGetDVCRImageName sync.RWMutex + lockGetDownloadSpeed sync.RWMutex + lockGetFormat sync.RWMutex + lockGetProgress sync.RWMutex + lockGetSize sync.RWMutex + lockIsUploadStarted sync.RWMutex + lockIsUploaderReady sync.RWMutex +} + +// CheckPod calls CheckPodFunc. +func (mock *UploadDataSourceStatServiceMock) CheckPod(pod *corev1.Pod) error { + if mock.CheckPodFunc == nil { + panic("UploadDataSourceStatServiceMock.CheckPodFunc: method is nil but UploadDataSourceStatService.CheckPod was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockCheckPod.Lock() + mock.calls.CheckPod = append(mock.calls.CheckPod, callInfo) + mock.lockCheckPod.Unlock() + return mock.CheckPodFunc(pod) +} + +// CheckPodCalls gets all the calls that were made to CheckPod. +// Check the length with: +// +// len(mockedUploadDataSourceStatService.CheckPodCalls()) +func (mock *UploadDataSourceStatServiceMock) CheckPodCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockCheckPod.RLock() + calls = mock.calls.CheckPod + mock.lockCheckPod.RUnlock() + return calls +} + +// GetDVCRImageName calls GetDVCRImageNameFunc. +func (mock *UploadDataSourceStatServiceMock) GetDVCRImageName(pod *corev1.Pod) string { + if mock.GetDVCRImageNameFunc == nil { + panic("UploadDataSourceStatServiceMock.GetDVCRImageNameFunc: method is nil but UploadDataSourceStatService.GetDVCRImageName was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetDVCRImageName.Lock() + mock.calls.GetDVCRImageName = append(mock.calls.GetDVCRImageName, callInfo) + mock.lockGetDVCRImageName.Unlock() + return mock.GetDVCRImageNameFunc(pod) +} + +// GetDVCRImageNameCalls gets all the calls that were made to GetDVCRImageName. +// Check the length with: +// +// len(mockedUploadDataSourceStatService.GetDVCRImageNameCalls()) +func (mock *UploadDataSourceStatServiceMock) GetDVCRImageNameCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetDVCRImageName.RLock() + calls = mock.calls.GetDVCRImageName + mock.lockGetDVCRImageName.RUnlock() + return calls +} + +// GetDownloadSpeed calls GetDownloadSpeedFunc. +func (mock *UploadDataSourceStatServiceMock) GetDownloadSpeed(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed { + if mock.GetDownloadSpeedFunc == nil { + panic("UploadDataSourceStatServiceMock.GetDownloadSpeedFunc: method is nil but UploadDataSourceStatService.GetDownloadSpeed was just called") + } + callInfo := struct { + OwnerUID types.UID + Pod *corev1.Pod + }{ + OwnerUID: ownerUID, + Pod: pod, + } + mock.lockGetDownloadSpeed.Lock() + mock.calls.GetDownloadSpeed = append(mock.calls.GetDownloadSpeed, callInfo) + mock.lockGetDownloadSpeed.Unlock() + return mock.GetDownloadSpeedFunc(ownerUID, pod) +} + +// GetDownloadSpeedCalls gets all the calls that were made to GetDownloadSpeed. +// Check the length with: +// +// len(mockedUploadDataSourceStatService.GetDownloadSpeedCalls()) +func (mock *UploadDataSourceStatServiceMock) GetDownloadSpeedCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod +} { + var calls []struct { + OwnerUID types.UID + Pod *corev1.Pod + } + mock.lockGetDownloadSpeed.RLock() + calls = mock.calls.GetDownloadSpeed + mock.lockGetDownloadSpeed.RUnlock() + return calls +} + +// GetFormat calls GetFormatFunc. +func (mock *UploadDataSourceStatServiceMock) GetFormat(pod *corev1.Pod) string { + if mock.GetFormatFunc == nil { + panic("UploadDataSourceStatServiceMock.GetFormatFunc: method is nil but UploadDataSourceStatService.GetFormat was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetFormat.Lock() + mock.calls.GetFormat = append(mock.calls.GetFormat, callInfo) + mock.lockGetFormat.Unlock() + return mock.GetFormatFunc(pod) +} + +// GetFormatCalls gets all the calls that were made to GetFormat. +// Check the length with: +// +// len(mockedUploadDataSourceStatService.GetFormatCalls()) +func (mock *UploadDataSourceStatServiceMock) GetFormatCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetFormat.RLock() + calls = mock.calls.GetFormat + mock.lockGetFormat.RUnlock() + return calls +} + +// GetProgress calls GetProgressFunc. +func (mock *UploadDataSourceStatServiceMock) GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { + if mock.GetProgressFunc == nil { + panic("UploadDataSourceStatServiceMock.GetProgressFunc: method is nil but UploadDataSourceStatService.GetProgress was just called") + } + callInfo := struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption + }{ + OwnerUID: ownerUID, + Pod: pod, + PrevProgress: prevProgress, + Opts: opts, + } + mock.lockGetProgress.Lock() + mock.calls.GetProgress = append(mock.calls.GetProgress, callInfo) + mock.lockGetProgress.Unlock() + return mock.GetProgressFunc(ownerUID, pod, prevProgress, opts...) +} + +// GetProgressCalls gets all the calls that were made to GetProgress. +// Check the length with: +// +// len(mockedUploadDataSourceStatService.GetProgressCalls()) +func (mock *UploadDataSourceStatServiceMock) GetProgressCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption +} { + var calls []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption + } + mock.lockGetProgress.RLock() + calls = mock.calls.GetProgress + mock.lockGetProgress.RUnlock() + return calls +} + +// GetSize calls GetSizeFunc. +func (mock *UploadDataSourceStatServiceMock) GetSize(pod *corev1.Pod) v1alpha2.ImageStatusSize { + if mock.GetSizeFunc == nil { + panic("UploadDataSourceStatServiceMock.GetSizeFunc: method is nil but UploadDataSourceStatService.GetSize was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetSize.Lock() + mock.calls.GetSize = append(mock.calls.GetSize, callInfo) + mock.lockGetSize.Unlock() + return mock.GetSizeFunc(pod) +} + +// GetSizeCalls gets all the calls that were made to GetSize. +// Check the length with: +// +// len(mockedUploadDataSourceStatService.GetSizeCalls()) +func (mock *UploadDataSourceStatServiceMock) GetSizeCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetSize.RLock() + calls = mock.calls.GetSize + mock.lockGetSize.RUnlock() + return calls +} + +// IsUploadStarted calls IsUploadStartedFunc. +func (mock *UploadDataSourceStatServiceMock) IsUploadStarted(ownerUID types.UID, pod *corev1.Pod) bool { + if mock.IsUploadStartedFunc == nil { + panic("UploadDataSourceStatServiceMock.IsUploadStartedFunc: method is nil but UploadDataSourceStatService.IsUploadStarted was just called") + } + callInfo := struct { + OwnerUID types.UID + Pod *corev1.Pod + }{ + OwnerUID: ownerUID, + Pod: pod, + } + mock.lockIsUploadStarted.Lock() + mock.calls.IsUploadStarted = append(mock.calls.IsUploadStarted, callInfo) + mock.lockIsUploadStarted.Unlock() + return mock.IsUploadStartedFunc(ownerUID, pod) +} + +// IsUploadStartedCalls gets all the calls that were made to IsUploadStarted. +// Check the length with: +// +// len(mockedUploadDataSourceStatService.IsUploadStartedCalls()) +func (mock *UploadDataSourceStatServiceMock) IsUploadStartedCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod +} { + var calls []struct { + OwnerUID types.UID + Pod *corev1.Pod + } + mock.lockIsUploadStarted.RLock() + calls = mock.calls.IsUploadStarted + mock.lockIsUploadStarted.RUnlock() + return calls +} + +// IsUploaderReady calls IsUploaderReadyFunc. +func (mock *UploadDataSourceStatServiceMock) IsUploaderReady(pod *corev1.Pod, svc *corev1.Service, ing *netv1.Ingress, tlsSecret *corev1.Secret) (bool, error) { + if mock.IsUploaderReadyFunc == nil { + panic("UploadDataSourceStatServiceMock.IsUploaderReadyFunc: method is nil but UploadDataSourceStatService.IsUploaderReady was just called") + } + callInfo := struct { + Pod *corev1.Pod + Svc *corev1.Service + Ing *netv1.Ingress + TlsSecret *corev1.Secret + }{ + Pod: pod, + Svc: svc, + Ing: ing, + TlsSecret: tlsSecret, + } + mock.lockIsUploaderReady.Lock() + mock.calls.IsUploaderReady = append(mock.calls.IsUploaderReady, callInfo) + mock.lockIsUploaderReady.Unlock() + return mock.IsUploaderReadyFunc(pod, svc, ing, tlsSecret) +} + +// IsUploaderReadyCalls gets all the calls that were made to IsUploaderReady. +// Check the length with: +// +// len(mockedUploadDataSourceStatService.IsUploaderReadyCalls()) +func (mock *UploadDataSourceStatServiceMock) IsUploaderReadyCalls() []struct { + Pod *corev1.Pod + Svc *corev1.Service + Ing *netv1.Ingress + TlsSecret *corev1.Secret +} { + var calls []struct { + Pod *corev1.Pod + Svc *corev1.Service + Ing *netv1.Ingress + TlsSecret *corev1.Secret + } + mock.lockIsUploaderReady.RLock() + calls = mock.calls.IsUploaderReady + mock.lockIsUploaderReady.RUnlock() + return calls +} + +// Ensure, that HTTPDataSourceDiskServiceMock does implement HTTPDataSourceDiskService. +// If this is not the case, regenerate this file with moq. +var _ HTTPDataSourceDiskService = &HTTPDataSourceDiskServiceMock{} + +// HTTPDataSourceDiskServiceMock is a mock implementation of HTTPDataSourceDiskService. +// +// func TestSomethingThatUsesHTTPDataSourceDiskService(t *testing.T) { +// +// // make and configure a mocked HTTPDataSourceDiskService +// mockedHTTPDataSourceDiskService := &HTTPDataSourceDiskServiceMock{ +// CleanUpFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUp method") +// }, +// CleanUpSupplementsFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUpSupplements method") +// }, +// GetCapacityFunc: func(pvc *corev1.PersistentVolumeClaim) string { +// panic("mock out the GetCapacity method") +// }, +// GetPersistentVolumeClaimFunc: func(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) { +// panic("mock out the GetPersistentVolumeClaim method") +// }, +// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +// panic("mock out the GetVolumeAndAccessModes method") +// }, +// } +// +// // use mockedHTTPDataSourceDiskService in code that requires HTTPDataSourceDiskService +// // and then make assertions. +// +// } +type HTTPDataSourceDiskServiceMock struct { + // CleanUpFunc mocks the CleanUp method. + CleanUpFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // CleanUpSupplementsFunc mocks the CleanUpSupplements method. + CleanUpSupplementsFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // GetCapacityFunc mocks the GetCapacity method. + GetCapacityFunc func(pvc *corev1.PersistentVolumeClaim) string + + // GetPersistentVolumeClaimFunc mocks the GetPersistentVolumeClaim method. + GetPersistentVolumeClaimFunc func(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) + + // GetVolumeAndAccessModesFunc mocks the GetVolumeAndAccessModes method. + GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) + + // calls tracks calls to the methods. + calls struct { + // CleanUp holds details about calls to the CleanUp method. + CleanUp []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // CleanUpSupplements holds details about calls to the CleanUpSupplements method. + CleanUpSupplements []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetCapacity holds details about calls to the GetCapacity method. + GetCapacity []struct { + // Pvc is the pvc argument value. + Pvc *corev1.PersistentVolumeClaim + } + // GetPersistentVolumeClaim holds details about calls to the GetPersistentVolumeClaim method. + GetPersistentVolumeClaim []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetVolumeAndAccessModes holds details about calls to the GetVolumeAndAccessModes method. + GetVolumeAndAccessModes []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Obj is the obj argument value. + Obj client.Object + // Sc is the sc argument value. + Sc *v1.StorageClass + } + } + lockCleanUp sync.RWMutex + lockCleanUpSupplements sync.RWMutex + lockGetCapacity sync.RWMutex + lockGetPersistentVolumeClaim sync.RWMutex + lockGetVolumeAndAccessModes sync.RWMutex +} + +// CleanUp calls CleanUpFunc. +func (mock *HTTPDataSourceDiskServiceMock) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpFunc == nil { + panic("HTTPDataSourceDiskServiceMock.CleanUpFunc: method is nil but HTTPDataSourceDiskService.CleanUp was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUp.Lock() + mock.calls.CleanUp = append(mock.calls.CleanUp, callInfo) + mock.lockCleanUp.Unlock() + return mock.CleanUpFunc(ctx, sup) +} + +// CleanUpCalls gets all the calls that were made to CleanUp. +// Check the length with: +// +// len(mockedHTTPDataSourceDiskService.CleanUpCalls()) +func (mock *HTTPDataSourceDiskServiceMock) CleanUpCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUp.RLock() + calls = mock.calls.CleanUp + mock.lockCleanUp.RUnlock() + return calls +} + +// CleanUpSupplements calls CleanUpSupplementsFunc. +func (mock *HTTPDataSourceDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpSupplementsFunc == nil { + panic("HTTPDataSourceDiskServiceMock.CleanUpSupplementsFunc: method is nil but HTTPDataSourceDiskService.CleanUpSupplements was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUpSupplements.Lock() + mock.calls.CleanUpSupplements = append(mock.calls.CleanUpSupplements, callInfo) + mock.lockCleanUpSupplements.Unlock() + return mock.CleanUpSupplementsFunc(ctx, sup) +} + +// CleanUpSupplementsCalls gets all the calls that were made to CleanUpSupplements. +// Check the length with: +// +// len(mockedHTTPDataSourceDiskService.CleanUpSupplementsCalls()) +func (mock *HTTPDataSourceDiskServiceMock) CleanUpSupplementsCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUpSupplements.RLock() + calls = mock.calls.CleanUpSupplements + mock.lockCleanUpSupplements.RUnlock() + return calls +} + +// GetCapacity calls GetCapacityFunc. +func (mock *HTTPDataSourceDiskServiceMock) GetCapacity(pvc *corev1.PersistentVolumeClaim) string { + if mock.GetCapacityFunc == nil { + panic("HTTPDataSourceDiskServiceMock.GetCapacityFunc: method is nil but HTTPDataSourceDiskService.GetCapacity was just called") + } + callInfo := struct { + Pvc *corev1.PersistentVolumeClaim + }{ + Pvc: pvc, + } + mock.lockGetCapacity.Lock() + mock.calls.GetCapacity = append(mock.calls.GetCapacity, callInfo) + mock.lockGetCapacity.Unlock() + return mock.GetCapacityFunc(pvc) +} + +// GetCapacityCalls gets all the calls that were made to GetCapacity. +// Check the length with: +// +// len(mockedHTTPDataSourceDiskService.GetCapacityCalls()) +func (mock *HTTPDataSourceDiskServiceMock) GetCapacityCalls() []struct { + Pvc *corev1.PersistentVolumeClaim +} { + var calls []struct { + Pvc *corev1.PersistentVolumeClaim + } + mock.lockGetCapacity.RLock() + calls = mock.calls.GetCapacity + mock.lockGetCapacity.RUnlock() + return calls +} + +// GetPersistentVolumeClaim calls GetPersistentVolumeClaimFunc. +func (mock *HTTPDataSourceDiskServiceMock) GetPersistentVolumeClaim(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + if mock.GetPersistentVolumeClaimFunc == nil { + panic("HTTPDataSourceDiskServiceMock.GetPersistentVolumeClaimFunc: method is nil but HTTPDataSourceDiskService.GetPersistentVolumeClaim was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockGetPersistentVolumeClaim.Lock() + mock.calls.GetPersistentVolumeClaim = append(mock.calls.GetPersistentVolumeClaim, callInfo) + mock.lockGetPersistentVolumeClaim.Unlock() + return mock.GetPersistentVolumeClaimFunc(ctx, sup) +} + +// GetPersistentVolumeClaimCalls gets all the calls that were made to GetPersistentVolumeClaim. +// Check the length with: +// +// len(mockedHTTPDataSourceDiskService.GetPersistentVolumeClaimCalls()) +func (mock *HTTPDataSourceDiskServiceMock) GetPersistentVolumeClaimCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockGetPersistentVolumeClaim.RLock() + calls = mock.calls.GetPersistentVolumeClaim + mock.lockGetPersistentVolumeClaim.RUnlock() + return calls +} + +// GetVolumeAndAccessModes calls GetVolumeAndAccessModesFunc. +func (mock *HTTPDataSourceDiskServiceMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + if mock.GetVolumeAndAccessModesFunc == nil { + panic("HTTPDataSourceDiskServiceMock.GetVolumeAndAccessModesFunc: method is nil but HTTPDataSourceDiskService.GetVolumeAndAccessModes was just called") + } + callInfo := struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass + }{ + Ctx: ctx, + Obj: obj, + Sc: sc, + } + mock.lockGetVolumeAndAccessModes.Lock() + mock.calls.GetVolumeAndAccessModes = append(mock.calls.GetVolumeAndAccessModes, callInfo) + mock.lockGetVolumeAndAccessModes.Unlock() + return mock.GetVolumeAndAccessModesFunc(ctx, obj, sc) +} + +// GetVolumeAndAccessModesCalls gets all the calls that were made to GetVolumeAndAccessModes. +// Check the length with: +// +// len(mockedHTTPDataSourceDiskService.GetVolumeAndAccessModesCalls()) +func (mock *HTTPDataSourceDiskServiceMock) GetVolumeAndAccessModesCalls() []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass +} { + var calls []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass + } + mock.lockGetVolumeAndAccessModes.RLock() + calls = mock.calls.GetVolumeAndAccessModes + mock.lockGetVolumeAndAccessModes.RUnlock() + return calls +} + +// Ensure, that HTTPDataSourceImporterServiceMock does implement HTTPDataSourceImporterService. +// If this is not the case, regenerate this file with moq. +var _ HTTPDataSourceImporterService = &HTTPDataSourceImporterServiceMock{} + +// HTTPDataSourceImporterServiceMock is a mock implementation of HTTPDataSourceImporterService. +// +// func TestSomethingThatUsesHTTPDataSourceImporterService(t *testing.T) { +// +// // make and configure a mocked HTTPDataSourceImporterService +// mockedHTTPDataSourceImporterService := &HTTPDataSourceImporterServiceMock{ +// CleanUpFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUp method") +// }, +// GetPodFunc: func(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) { +// panic("mock out the GetPod method") +// }, +// ProtectFunc: func(ctx context.Context, pod *corev1.Pod, sup supplements.Generator) error { +// panic("mock out the Protect method") +// }, +// StartFunc: func(ctx context.Context, settings *importer.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error { +// panic("mock out the Start method") +// }, +// } +// +// // use mockedHTTPDataSourceImporterService in code that requires HTTPDataSourceImporterService +// // and then make assertions. +// +// } +type HTTPDataSourceImporterServiceMock struct { + // CleanUpFunc mocks the CleanUp method. + CleanUpFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // GetPodFunc mocks the GetPod method. + GetPodFunc func(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) + + // ProtectFunc mocks the Protect method. + ProtectFunc func(ctx context.Context, pod *corev1.Pod, sup supplements.Generator) error + + // StartFunc mocks the Start method. + StartFunc func(ctx context.Context, settings *importer.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error + + // calls tracks calls to the methods. + calls struct { + // CleanUp holds details about calls to the CleanUp method. + CleanUp []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetPod holds details about calls to the GetPod method. + GetPod []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // Protect holds details about calls to the Protect method. + Protect []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Pod is the pod argument value. + Pod *corev1.Pod + // Sup is the sup argument value. + Sup supplements.Generator + } + // Start holds details about calls to the Start method. + Start []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Settings is the settings argument value. + Settings *importer.Settings + // Obj is the obj argument value. + Obj client.Object + // Sup is the sup argument value. + Sup supplements.Generator + // CaBundle is the caBundle argument value. + CaBundle *datasource.CABundle + // Opts is the opts argument value. + Opts []service.Option + } + } + lockCleanUp sync.RWMutex + lockGetPod sync.RWMutex + lockProtect sync.RWMutex + lockStart sync.RWMutex +} + +// CleanUp calls CleanUpFunc. +func (mock *HTTPDataSourceImporterServiceMock) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpFunc == nil { + panic("HTTPDataSourceImporterServiceMock.CleanUpFunc: method is nil but HTTPDataSourceImporterService.CleanUp was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUp.Lock() + mock.calls.CleanUp = append(mock.calls.CleanUp, callInfo) + mock.lockCleanUp.Unlock() + return mock.CleanUpFunc(ctx, sup) +} + +// CleanUpCalls gets all the calls that were made to CleanUp. +// Check the length with: +// +// len(mockedHTTPDataSourceImporterService.CleanUpCalls()) +func (mock *HTTPDataSourceImporterServiceMock) CleanUpCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUp.RLock() + calls = mock.calls.CleanUp + mock.lockCleanUp.RUnlock() + return calls +} + +// GetPod calls GetPodFunc. +func (mock *HTTPDataSourceImporterServiceMock) GetPod(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) { + if mock.GetPodFunc == nil { + panic("HTTPDataSourceImporterServiceMock.GetPodFunc: method is nil but HTTPDataSourceImporterService.GetPod was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockGetPod.Lock() + mock.calls.GetPod = append(mock.calls.GetPod, callInfo) + mock.lockGetPod.Unlock() + return mock.GetPodFunc(ctx, sup) +} + +// GetPodCalls gets all the calls that were made to GetPod. +// Check the length with: +// +// len(mockedHTTPDataSourceImporterService.GetPodCalls()) +func (mock *HTTPDataSourceImporterServiceMock) GetPodCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockGetPod.RLock() + calls = mock.calls.GetPod + mock.lockGetPod.RUnlock() + return calls +} + +// Protect calls ProtectFunc. +func (mock *HTTPDataSourceImporterServiceMock) Protect(ctx context.Context, pod *corev1.Pod, sup supplements.Generator) error { + if mock.ProtectFunc == nil { + panic("HTTPDataSourceImporterServiceMock.ProtectFunc: method is nil but HTTPDataSourceImporterService.Protect was just called") + } + callInfo := struct { + Ctx context.Context + Pod *corev1.Pod + Sup supplements.Generator + }{ + Ctx: ctx, + Pod: pod, + Sup: sup, + } + mock.lockProtect.Lock() + mock.calls.Protect = append(mock.calls.Protect, callInfo) + mock.lockProtect.Unlock() + return mock.ProtectFunc(ctx, pod, sup) +} + +// ProtectCalls gets all the calls that were made to Protect. +// Check the length with: +// +// len(mockedHTTPDataSourceImporterService.ProtectCalls()) +func (mock *HTTPDataSourceImporterServiceMock) ProtectCalls() []struct { + Ctx context.Context + Pod *corev1.Pod + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Pod *corev1.Pod + Sup supplements.Generator + } + mock.lockProtect.RLock() + calls = mock.calls.Protect + mock.lockProtect.RUnlock() + return calls +} + +// Start calls StartFunc. +func (mock *HTTPDataSourceImporterServiceMock) Start(ctx context.Context, settings *importer.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error { + if mock.StartFunc == nil { + panic("HTTPDataSourceImporterServiceMock.StartFunc: method is nil but HTTPDataSourceImporterService.Start was just called") + } + callInfo := struct { + Ctx context.Context + Settings *importer.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option + }{ + Ctx: ctx, + Settings: settings, + Obj: obj, + Sup: sup, + CaBundle: caBundle, + Opts: opts, + } + mock.lockStart.Lock() + mock.calls.Start = append(mock.calls.Start, callInfo) + mock.lockStart.Unlock() + return mock.StartFunc(ctx, settings, obj, sup, caBundle, opts...) +} + +// StartCalls gets all the calls that were made to Start. +// Check the length with: +// +// len(mockedHTTPDataSourceImporterService.StartCalls()) +func (mock *HTTPDataSourceImporterServiceMock) StartCalls() []struct { + Ctx context.Context + Settings *importer.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option +} { + var calls []struct { + Ctx context.Context + Settings *importer.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option + } + mock.lockStart.RLock() + calls = mock.calls.Start + mock.lockStart.RUnlock() + return calls +} + +// Ensure, that HTTPDataSourceStatServiceMock does implement HTTPDataSourceStatService. +// If this is not the case, regenerate this file with moq. +var _ HTTPDataSourceStatService = &HTTPDataSourceStatServiceMock{} + +// HTTPDataSourceStatServiceMock is a mock implementation of HTTPDataSourceStatService. +// +// func TestSomethingThatUsesHTTPDataSourceStatService(t *testing.T) { +// +// // make and configure a mocked HTTPDataSourceStatService +// mockedHTTPDataSourceStatService := &HTTPDataSourceStatServiceMock{ +// CheckPodFunc: func(pod *corev1.Pod) error { +// panic("mock out the CheckPod method") +// }, +// GetDVCRImageNameFunc: func(pod *corev1.Pod) string { +// panic("mock out the GetDVCRImageName method") +// }, +// GetDownloadSpeedFunc: func(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed { +// panic("mock out the GetDownloadSpeed method") +// }, +// GetFormatFunc: func(pod *corev1.Pod) string { +// panic("mock out the GetFormat method") +// }, +// GetProgressFunc: func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { +// panic("mock out the GetProgress method") +// }, +// GetSizeFunc: func(pod *corev1.Pod) v1alpha2.ImageStatusSize { +// panic("mock out the GetSize method") +// }, +// } +// +// // use mockedHTTPDataSourceStatService in code that requires HTTPDataSourceStatService +// // and then make assertions. +// +// } +type HTTPDataSourceStatServiceMock struct { + // CheckPodFunc mocks the CheckPod method. + CheckPodFunc func(pod *corev1.Pod) error + + // GetDVCRImageNameFunc mocks the GetDVCRImageName method. + GetDVCRImageNameFunc func(pod *corev1.Pod) string + + // GetDownloadSpeedFunc mocks the GetDownloadSpeed method. + GetDownloadSpeedFunc func(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed + + // GetFormatFunc mocks the GetFormat method. + GetFormatFunc func(pod *corev1.Pod) string + + // GetProgressFunc mocks the GetProgress method. + GetProgressFunc func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string + + // GetSizeFunc mocks the GetSize method. + GetSizeFunc func(pod *corev1.Pod) v1alpha2.ImageStatusSize + + // calls tracks calls to the methods. + calls struct { + // CheckPod holds details about calls to the CheckPod method. + CheckPod []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetDVCRImageName holds details about calls to the GetDVCRImageName method. + GetDVCRImageName []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetDownloadSpeed holds details about calls to the GetDownloadSpeed method. + GetDownloadSpeed []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetFormat holds details about calls to the GetFormat method. + GetFormat []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetProgress holds details about calls to the GetProgress method. + GetProgress []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + // PrevProgress is the prevProgress argument value. + PrevProgress string + // Opts is the opts argument value. + Opts []service.GetProgressOption + } + // GetSize holds details about calls to the GetSize method. + GetSize []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + } + lockCheckPod sync.RWMutex + lockGetDVCRImageName sync.RWMutex + lockGetDownloadSpeed sync.RWMutex + lockGetFormat sync.RWMutex + lockGetProgress sync.RWMutex + lockGetSize sync.RWMutex +} + +// CheckPod calls CheckPodFunc. +func (mock *HTTPDataSourceStatServiceMock) CheckPod(pod *corev1.Pod) error { + if mock.CheckPodFunc == nil { + panic("HTTPDataSourceStatServiceMock.CheckPodFunc: method is nil but HTTPDataSourceStatService.CheckPod was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockCheckPod.Lock() + mock.calls.CheckPod = append(mock.calls.CheckPod, callInfo) + mock.lockCheckPod.Unlock() + return mock.CheckPodFunc(pod) +} + +// CheckPodCalls gets all the calls that were made to CheckPod. +// Check the length with: +// +// len(mockedHTTPDataSourceStatService.CheckPodCalls()) +func (mock *HTTPDataSourceStatServiceMock) CheckPodCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockCheckPod.RLock() + calls = mock.calls.CheckPod + mock.lockCheckPod.RUnlock() + return calls +} + +// GetDVCRImageName calls GetDVCRImageNameFunc. +func (mock *HTTPDataSourceStatServiceMock) GetDVCRImageName(pod *corev1.Pod) string { + if mock.GetDVCRImageNameFunc == nil { + panic("HTTPDataSourceStatServiceMock.GetDVCRImageNameFunc: method is nil but HTTPDataSourceStatService.GetDVCRImageName was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetDVCRImageName.Lock() + mock.calls.GetDVCRImageName = append(mock.calls.GetDVCRImageName, callInfo) + mock.lockGetDVCRImageName.Unlock() + return mock.GetDVCRImageNameFunc(pod) +} + +// GetDVCRImageNameCalls gets all the calls that were made to GetDVCRImageName. +// Check the length with: +// +// len(mockedHTTPDataSourceStatService.GetDVCRImageNameCalls()) +func (mock *HTTPDataSourceStatServiceMock) GetDVCRImageNameCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetDVCRImageName.RLock() + calls = mock.calls.GetDVCRImageName + mock.lockGetDVCRImageName.RUnlock() + return calls +} + +// GetDownloadSpeed calls GetDownloadSpeedFunc. +func (mock *HTTPDataSourceStatServiceMock) GetDownloadSpeed(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed { + if mock.GetDownloadSpeedFunc == nil { + panic("HTTPDataSourceStatServiceMock.GetDownloadSpeedFunc: method is nil but HTTPDataSourceStatService.GetDownloadSpeed was just called") + } + callInfo := struct { + OwnerUID types.UID + Pod *corev1.Pod + }{ + OwnerUID: ownerUID, + Pod: pod, + } + mock.lockGetDownloadSpeed.Lock() + mock.calls.GetDownloadSpeed = append(mock.calls.GetDownloadSpeed, callInfo) + mock.lockGetDownloadSpeed.Unlock() + return mock.GetDownloadSpeedFunc(ownerUID, pod) +} + +// GetDownloadSpeedCalls gets all the calls that were made to GetDownloadSpeed. +// Check the length with: +// +// len(mockedHTTPDataSourceStatService.GetDownloadSpeedCalls()) +func (mock *HTTPDataSourceStatServiceMock) GetDownloadSpeedCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod +} { + var calls []struct { + OwnerUID types.UID + Pod *corev1.Pod + } + mock.lockGetDownloadSpeed.RLock() + calls = mock.calls.GetDownloadSpeed + mock.lockGetDownloadSpeed.RUnlock() + return calls +} + +// GetFormat calls GetFormatFunc. +func (mock *HTTPDataSourceStatServiceMock) GetFormat(pod *corev1.Pod) string { + if mock.GetFormatFunc == nil { + panic("HTTPDataSourceStatServiceMock.GetFormatFunc: method is nil but HTTPDataSourceStatService.GetFormat was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetFormat.Lock() + mock.calls.GetFormat = append(mock.calls.GetFormat, callInfo) + mock.lockGetFormat.Unlock() + return mock.GetFormatFunc(pod) +} + +// GetFormatCalls gets all the calls that were made to GetFormat. +// Check the length with: +// +// len(mockedHTTPDataSourceStatService.GetFormatCalls()) +func (mock *HTTPDataSourceStatServiceMock) GetFormatCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetFormat.RLock() + calls = mock.calls.GetFormat + mock.lockGetFormat.RUnlock() + return calls +} + +// GetProgress calls GetProgressFunc. +func (mock *HTTPDataSourceStatServiceMock) GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { + if mock.GetProgressFunc == nil { + panic("HTTPDataSourceStatServiceMock.GetProgressFunc: method is nil but HTTPDataSourceStatService.GetProgress was just called") + } + callInfo := struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption + }{ + OwnerUID: ownerUID, + Pod: pod, + PrevProgress: prevProgress, + Opts: opts, + } + mock.lockGetProgress.Lock() + mock.calls.GetProgress = append(mock.calls.GetProgress, callInfo) + mock.lockGetProgress.Unlock() + return mock.GetProgressFunc(ownerUID, pod, prevProgress, opts...) +} + +// GetProgressCalls gets all the calls that were made to GetProgress. +// Check the length with: +// +// len(mockedHTTPDataSourceStatService.GetProgressCalls()) +func (mock *HTTPDataSourceStatServiceMock) GetProgressCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption +} { + var calls []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption + } + mock.lockGetProgress.RLock() + calls = mock.calls.GetProgress + mock.lockGetProgress.RUnlock() + return calls +} + +// GetSize calls GetSizeFunc. +func (mock *HTTPDataSourceStatServiceMock) GetSize(pod *corev1.Pod) v1alpha2.ImageStatusSize { + if mock.GetSizeFunc == nil { + panic("HTTPDataSourceStatServiceMock.GetSizeFunc: method is nil but HTTPDataSourceStatService.GetSize was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetSize.Lock() + mock.calls.GetSize = append(mock.calls.GetSize, callInfo) + mock.lockGetSize.Unlock() + return mock.GetSizeFunc(pod) +} + +// GetSizeCalls gets all the calls that were made to GetSize. +// Check the length with: +// +// len(mockedHTTPDataSourceStatService.GetSizeCalls()) +func (mock *HTTPDataSourceStatServiceMock) GetSizeCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetSize.RLock() + calls = mock.calls.GetSize + mock.lockGetSize.RUnlock() + return calls +} + +// Ensure, that RegistryDataSourceDiskServiceMock does implement RegistryDataSourceDiskService. +// If this is not the case, regenerate this file with moq. +var _ RegistryDataSourceDiskService = &RegistryDataSourceDiskServiceMock{} + +// RegistryDataSourceDiskServiceMock is a mock implementation of RegistryDataSourceDiskService. +// +// func TestSomethingThatUsesRegistryDataSourceDiskService(t *testing.T) { +// +// // make and configure a mocked RegistryDataSourceDiskService +// mockedRegistryDataSourceDiskService := &RegistryDataSourceDiskServiceMock{ +// CleanUpFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUp method") +// }, +// CleanUpSupplementsFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUpSupplements method") +// }, +// GetCapacityFunc: func(pvc *corev1.PersistentVolumeClaim) string { +// panic("mock out the GetCapacity method") +// }, +// GetPersistentVolumeClaimFunc: func(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) { +// panic("mock out the GetPersistentVolumeClaim method") +// }, +// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +// panic("mock out the GetVolumeAndAccessModes method") +// }, +// } +// +// // use mockedRegistryDataSourceDiskService in code that requires RegistryDataSourceDiskService +// // and then make assertions. +// +// } +type RegistryDataSourceDiskServiceMock struct { + // CleanUpFunc mocks the CleanUp method. + CleanUpFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // CleanUpSupplementsFunc mocks the CleanUpSupplements method. + CleanUpSupplementsFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // GetCapacityFunc mocks the GetCapacity method. + GetCapacityFunc func(pvc *corev1.PersistentVolumeClaim) string + + // GetPersistentVolumeClaimFunc mocks the GetPersistentVolumeClaim method. + GetPersistentVolumeClaimFunc func(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) + + // GetVolumeAndAccessModesFunc mocks the GetVolumeAndAccessModes method. + GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) + + // calls tracks calls to the methods. + calls struct { + // CleanUp holds details about calls to the CleanUp method. + CleanUp []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // CleanUpSupplements holds details about calls to the CleanUpSupplements method. + CleanUpSupplements []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetCapacity holds details about calls to the GetCapacity method. + GetCapacity []struct { + // Pvc is the pvc argument value. + Pvc *corev1.PersistentVolumeClaim + } + // GetPersistentVolumeClaim holds details about calls to the GetPersistentVolumeClaim method. + GetPersistentVolumeClaim []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetVolumeAndAccessModes holds details about calls to the GetVolumeAndAccessModes method. + GetVolumeAndAccessModes []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Obj is the obj argument value. + Obj client.Object + // Sc is the sc argument value. + Sc *v1.StorageClass + } + } + lockCleanUp sync.RWMutex + lockCleanUpSupplements sync.RWMutex + lockGetCapacity sync.RWMutex + lockGetPersistentVolumeClaim sync.RWMutex + lockGetVolumeAndAccessModes sync.RWMutex +} + +// CleanUp calls CleanUpFunc. +func (mock *RegistryDataSourceDiskServiceMock) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpFunc == nil { + panic("RegistryDataSourceDiskServiceMock.CleanUpFunc: method is nil but RegistryDataSourceDiskService.CleanUp was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUp.Lock() + mock.calls.CleanUp = append(mock.calls.CleanUp, callInfo) + mock.lockCleanUp.Unlock() + return mock.CleanUpFunc(ctx, sup) +} + +// CleanUpCalls gets all the calls that were made to CleanUp. +// Check the length with: +// +// len(mockedRegistryDataSourceDiskService.CleanUpCalls()) +func (mock *RegistryDataSourceDiskServiceMock) CleanUpCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUp.RLock() + calls = mock.calls.CleanUp + mock.lockCleanUp.RUnlock() + return calls +} + +// CleanUpSupplements calls CleanUpSupplementsFunc. +func (mock *RegistryDataSourceDiskServiceMock) CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpSupplementsFunc == nil { + panic("RegistryDataSourceDiskServiceMock.CleanUpSupplementsFunc: method is nil but RegistryDataSourceDiskService.CleanUpSupplements was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUpSupplements.Lock() + mock.calls.CleanUpSupplements = append(mock.calls.CleanUpSupplements, callInfo) + mock.lockCleanUpSupplements.Unlock() + return mock.CleanUpSupplementsFunc(ctx, sup) +} + +// CleanUpSupplementsCalls gets all the calls that were made to CleanUpSupplements. +// Check the length with: +// +// len(mockedRegistryDataSourceDiskService.CleanUpSupplementsCalls()) +func (mock *RegistryDataSourceDiskServiceMock) CleanUpSupplementsCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUpSupplements.RLock() + calls = mock.calls.CleanUpSupplements + mock.lockCleanUpSupplements.RUnlock() + return calls +} + +// GetCapacity calls GetCapacityFunc. +func (mock *RegistryDataSourceDiskServiceMock) GetCapacity(pvc *corev1.PersistentVolumeClaim) string { + if mock.GetCapacityFunc == nil { + panic("RegistryDataSourceDiskServiceMock.GetCapacityFunc: method is nil but RegistryDataSourceDiskService.GetCapacity was just called") + } + callInfo := struct { + Pvc *corev1.PersistentVolumeClaim + }{ + Pvc: pvc, + } + mock.lockGetCapacity.Lock() + mock.calls.GetCapacity = append(mock.calls.GetCapacity, callInfo) + mock.lockGetCapacity.Unlock() + return mock.GetCapacityFunc(pvc) +} + +// GetCapacityCalls gets all the calls that were made to GetCapacity. +// Check the length with: +// +// len(mockedRegistryDataSourceDiskService.GetCapacityCalls()) +func (mock *RegistryDataSourceDiskServiceMock) GetCapacityCalls() []struct { + Pvc *corev1.PersistentVolumeClaim +} { + var calls []struct { + Pvc *corev1.PersistentVolumeClaim + } + mock.lockGetCapacity.RLock() + calls = mock.calls.GetCapacity + mock.lockGetCapacity.RUnlock() + return calls +} + +// GetPersistentVolumeClaim calls GetPersistentVolumeClaimFunc. +func (mock *RegistryDataSourceDiskServiceMock) GetPersistentVolumeClaim(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + if mock.GetPersistentVolumeClaimFunc == nil { + panic("RegistryDataSourceDiskServiceMock.GetPersistentVolumeClaimFunc: method is nil but RegistryDataSourceDiskService.GetPersistentVolumeClaim was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockGetPersistentVolumeClaim.Lock() + mock.calls.GetPersistentVolumeClaim = append(mock.calls.GetPersistentVolumeClaim, callInfo) + mock.lockGetPersistentVolumeClaim.Unlock() + return mock.GetPersistentVolumeClaimFunc(ctx, sup) +} + +// GetPersistentVolumeClaimCalls gets all the calls that were made to GetPersistentVolumeClaim. +// Check the length with: +// +// len(mockedRegistryDataSourceDiskService.GetPersistentVolumeClaimCalls()) +func (mock *RegistryDataSourceDiskServiceMock) GetPersistentVolumeClaimCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockGetPersistentVolumeClaim.RLock() + calls = mock.calls.GetPersistentVolumeClaim + mock.lockGetPersistentVolumeClaim.RUnlock() + return calls +} + +// GetVolumeAndAccessModes calls GetVolumeAndAccessModesFunc. +func (mock *RegistryDataSourceDiskServiceMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *v1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + if mock.GetVolumeAndAccessModesFunc == nil { + panic("RegistryDataSourceDiskServiceMock.GetVolumeAndAccessModesFunc: method is nil but RegistryDataSourceDiskService.GetVolumeAndAccessModes was just called") + } + callInfo := struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass + }{ + Ctx: ctx, + Obj: obj, + Sc: sc, + } + mock.lockGetVolumeAndAccessModes.Lock() + mock.calls.GetVolumeAndAccessModes = append(mock.calls.GetVolumeAndAccessModes, callInfo) + mock.lockGetVolumeAndAccessModes.Unlock() + return mock.GetVolumeAndAccessModesFunc(ctx, obj, sc) +} + +// GetVolumeAndAccessModesCalls gets all the calls that were made to GetVolumeAndAccessModes. +// Check the length with: +// +// len(mockedRegistryDataSourceDiskService.GetVolumeAndAccessModesCalls()) +func (mock *RegistryDataSourceDiskServiceMock) GetVolumeAndAccessModesCalls() []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass +} { + var calls []struct { + Ctx context.Context + Obj client.Object + Sc *v1.StorageClass + } + mock.lockGetVolumeAndAccessModes.RLock() + calls = mock.calls.GetVolumeAndAccessModes + mock.lockGetVolumeAndAccessModes.RUnlock() + return calls +} + +// Ensure, that RegistryDataSourceImporterServiceMock does implement RegistryDataSourceImporterService. +// If this is not the case, regenerate this file with moq. +var _ RegistryDataSourceImporterService = &RegistryDataSourceImporterServiceMock{} + +// RegistryDataSourceImporterServiceMock is a mock implementation of RegistryDataSourceImporterService. +// +// func TestSomethingThatUsesRegistryDataSourceImporterService(t *testing.T) { +// +// // make and configure a mocked RegistryDataSourceImporterService +// mockedRegistryDataSourceImporterService := &RegistryDataSourceImporterServiceMock{ +// CleanUpFunc: func(ctx context.Context, sup supplements.Generator) (bool, error) { +// panic("mock out the CleanUp method") +// }, +// GetPodFunc: func(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) { +// panic("mock out the GetPod method") +// }, +// ProtectFunc: func(ctx context.Context, pod *corev1.Pod, sup supplements.Generator) error { +// panic("mock out the Protect method") +// }, +// StartFunc: func(ctx context.Context, settings *importer.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error { +// panic("mock out the Start method") +// }, +// } +// +// // use mockedRegistryDataSourceImporterService in code that requires RegistryDataSourceImporterService +// // and then make assertions. +// +// } +type RegistryDataSourceImporterServiceMock struct { + // CleanUpFunc mocks the CleanUp method. + CleanUpFunc func(ctx context.Context, sup supplements.Generator) (bool, error) + + // GetPodFunc mocks the GetPod method. + GetPodFunc func(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) + + // ProtectFunc mocks the Protect method. + ProtectFunc func(ctx context.Context, pod *corev1.Pod, sup supplements.Generator) error + + // StartFunc mocks the Start method. + StartFunc func(ctx context.Context, settings *importer.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error + + // calls tracks calls to the methods. + calls struct { + // CleanUp holds details about calls to the CleanUp method. + CleanUp []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // GetPod holds details about calls to the GetPod method. + GetPod []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Sup is the sup argument value. + Sup supplements.Generator + } + // Protect holds details about calls to the Protect method. + Protect []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Pod is the pod argument value. + Pod *corev1.Pod + // Sup is the sup argument value. + Sup supplements.Generator + } + // Start holds details about calls to the Start method. + Start []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Settings is the settings argument value. + Settings *importer.Settings + // Obj is the obj argument value. + Obj client.Object + // Sup is the sup argument value. + Sup supplements.Generator + // CaBundle is the caBundle argument value. + CaBundle *datasource.CABundle + // Opts is the opts argument value. + Opts []service.Option + } + } + lockCleanUp sync.RWMutex + lockGetPod sync.RWMutex + lockProtect sync.RWMutex + lockStart sync.RWMutex +} + +// CleanUp calls CleanUpFunc. +func (mock *RegistryDataSourceImporterServiceMock) CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) { + if mock.CleanUpFunc == nil { + panic("RegistryDataSourceImporterServiceMock.CleanUpFunc: method is nil but RegistryDataSourceImporterService.CleanUp was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockCleanUp.Lock() + mock.calls.CleanUp = append(mock.calls.CleanUp, callInfo) + mock.lockCleanUp.Unlock() + return mock.CleanUpFunc(ctx, sup) +} + +// CleanUpCalls gets all the calls that were made to CleanUp. +// Check the length with: +// +// len(mockedRegistryDataSourceImporterService.CleanUpCalls()) +func (mock *RegistryDataSourceImporterServiceMock) CleanUpCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockCleanUp.RLock() + calls = mock.calls.CleanUp + mock.lockCleanUp.RUnlock() + return calls +} + +// GetPod calls GetPodFunc. +func (mock *RegistryDataSourceImporterServiceMock) GetPod(ctx context.Context, sup supplements.Generator) (*corev1.Pod, error) { + if mock.GetPodFunc == nil { + panic("RegistryDataSourceImporterServiceMock.GetPodFunc: method is nil but RegistryDataSourceImporterService.GetPod was just called") + } + callInfo := struct { + Ctx context.Context + Sup supplements.Generator + }{ + Ctx: ctx, + Sup: sup, + } + mock.lockGetPod.Lock() + mock.calls.GetPod = append(mock.calls.GetPod, callInfo) + mock.lockGetPod.Unlock() + return mock.GetPodFunc(ctx, sup) +} + +// GetPodCalls gets all the calls that were made to GetPod. +// Check the length with: +// +// len(mockedRegistryDataSourceImporterService.GetPodCalls()) +func (mock *RegistryDataSourceImporterServiceMock) GetPodCalls() []struct { + Ctx context.Context + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Sup supplements.Generator + } + mock.lockGetPod.RLock() + calls = mock.calls.GetPod + mock.lockGetPod.RUnlock() + return calls +} + +// Protect calls ProtectFunc. +func (mock *RegistryDataSourceImporterServiceMock) Protect(ctx context.Context, pod *corev1.Pod, sup supplements.Generator) error { + if mock.ProtectFunc == nil { + panic("RegistryDataSourceImporterServiceMock.ProtectFunc: method is nil but RegistryDataSourceImporterService.Protect was just called") + } + callInfo := struct { + Ctx context.Context + Pod *corev1.Pod + Sup supplements.Generator + }{ + Ctx: ctx, + Pod: pod, + Sup: sup, + } + mock.lockProtect.Lock() + mock.calls.Protect = append(mock.calls.Protect, callInfo) + mock.lockProtect.Unlock() + return mock.ProtectFunc(ctx, pod, sup) +} + +// ProtectCalls gets all the calls that were made to Protect. +// Check the length with: +// +// len(mockedRegistryDataSourceImporterService.ProtectCalls()) +func (mock *RegistryDataSourceImporterServiceMock) ProtectCalls() []struct { + Ctx context.Context + Pod *corev1.Pod + Sup supplements.Generator +} { + var calls []struct { + Ctx context.Context + Pod *corev1.Pod + Sup supplements.Generator + } + mock.lockProtect.RLock() + calls = mock.calls.Protect + mock.lockProtect.RUnlock() + return calls +} + +// Start calls StartFunc. +func (mock *RegistryDataSourceImporterServiceMock) Start(ctx context.Context, settings *importer.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error { + if mock.StartFunc == nil { + panic("RegistryDataSourceImporterServiceMock.StartFunc: method is nil but RegistryDataSourceImporterService.Start was just called") + } + callInfo := struct { + Ctx context.Context + Settings *importer.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option + }{ + Ctx: ctx, + Settings: settings, + Obj: obj, + Sup: sup, + CaBundle: caBundle, + Opts: opts, + } + mock.lockStart.Lock() + mock.calls.Start = append(mock.calls.Start, callInfo) + mock.lockStart.Unlock() + return mock.StartFunc(ctx, settings, obj, sup, caBundle, opts...) +} + +// StartCalls gets all the calls that were made to Start. +// Check the length with: +// +// len(mockedRegistryDataSourceImporterService.StartCalls()) +func (mock *RegistryDataSourceImporterServiceMock) StartCalls() []struct { + Ctx context.Context + Settings *importer.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option +} { + var calls []struct { + Ctx context.Context + Settings *importer.Settings + Obj client.Object + Sup supplements.Generator + CaBundle *datasource.CABundle + Opts []service.Option + } + mock.lockStart.RLock() + calls = mock.calls.Start + mock.lockStart.RUnlock() + return calls +} + +// Ensure, that RegistryDataSourceStatServiceMock does implement RegistryDataSourceStatService. +// If this is not the case, regenerate this file with moq. +var _ RegistryDataSourceStatService = &RegistryDataSourceStatServiceMock{} + +// RegistryDataSourceStatServiceMock is a mock implementation of RegistryDataSourceStatService. +// +// func TestSomethingThatUsesRegistryDataSourceStatService(t *testing.T) { +// +// // make and configure a mocked RegistryDataSourceStatService +// mockedRegistryDataSourceStatService := &RegistryDataSourceStatServiceMock{ +// CheckPodFunc: func(pod *corev1.Pod) error { +// panic("mock out the CheckPod method") +// }, +// GetDVCRImageNameFunc: func(pod *corev1.Pod) string { +// panic("mock out the GetDVCRImageName method") +// }, +// GetDownloadSpeedFunc: func(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed { +// panic("mock out the GetDownloadSpeed method") +// }, +// GetFormatFunc: func(pod *corev1.Pod) string { +// panic("mock out the GetFormat method") +// }, +// GetProgressFunc: func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { +// panic("mock out the GetProgress method") +// }, +// GetSizeFunc: func(pod *corev1.Pod) v1alpha2.ImageStatusSize { +// panic("mock out the GetSize method") +// }, +// } +// +// // use mockedRegistryDataSourceStatService in code that requires RegistryDataSourceStatService +// // and then make assertions. +// +// } +type RegistryDataSourceStatServiceMock struct { + // CheckPodFunc mocks the CheckPod method. + CheckPodFunc func(pod *corev1.Pod) error + + // GetDVCRImageNameFunc mocks the GetDVCRImageName method. + GetDVCRImageNameFunc func(pod *corev1.Pod) string + + // GetDownloadSpeedFunc mocks the GetDownloadSpeed method. + GetDownloadSpeedFunc func(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed + + // GetFormatFunc mocks the GetFormat method. + GetFormatFunc func(pod *corev1.Pod) string + + // GetProgressFunc mocks the GetProgress method. + GetProgressFunc func(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string + + // GetSizeFunc mocks the GetSize method. + GetSizeFunc func(pod *corev1.Pod) v1alpha2.ImageStatusSize + + // calls tracks calls to the methods. + calls struct { + // CheckPod holds details about calls to the CheckPod method. + CheckPod []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetDVCRImageName holds details about calls to the GetDVCRImageName method. + GetDVCRImageName []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetDownloadSpeed holds details about calls to the GetDownloadSpeed method. + GetDownloadSpeed []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetFormat holds details about calls to the GetFormat method. + GetFormat []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + // GetProgress holds details about calls to the GetProgress method. + GetProgress []struct { + // OwnerUID is the ownerUID argument value. + OwnerUID types.UID + // Pod is the pod argument value. + Pod *corev1.Pod + // PrevProgress is the prevProgress argument value. + PrevProgress string + // Opts is the opts argument value. + Opts []service.GetProgressOption + } + // GetSize holds details about calls to the GetSize method. + GetSize []struct { + // Pod is the pod argument value. + Pod *corev1.Pod + } + } + lockCheckPod sync.RWMutex + lockGetDVCRImageName sync.RWMutex + lockGetDownloadSpeed sync.RWMutex + lockGetFormat sync.RWMutex + lockGetProgress sync.RWMutex + lockGetSize sync.RWMutex +} + +// CheckPod calls CheckPodFunc. +func (mock *RegistryDataSourceStatServiceMock) CheckPod(pod *corev1.Pod) error { + if mock.CheckPodFunc == nil { + panic("RegistryDataSourceStatServiceMock.CheckPodFunc: method is nil but RegistryDataSourceStatService.CheckPod was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockCheckPod.Lock() + mock.calls.CheckPod = append(mock.calls.CheckPod, callInfo) + mock.lockCheckPod.Unlock() + return mock.CheckPodFunc(pod) +} + +// CheckPodCalls gets all the calls that were made to CheckPod. +// Check the length with: +// +// len(mockedRegistryDataSourceStatService.CheckPodCalls()) +func (mock *RegistryDataSourceStatServiceMock) CheckPodCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockCheckPod.RLock() + calls = mock.calls.CheckPod + mock.lockCheckPod.RUnlock() + return calls +} + +// GetDVCRImageName calls GetDVCRImageNameFunc. +func (mock *RegistryDataSourceStatServiceMock) GetDVCRImageName(pod *corev1.Pod) string { + if mock.GetDVCRImageNameFunc == nil { + panic("RegistryDataSourceStatServiceMock.GetDVCRImageNameFunc: method is nil but RegistryDataSourceStatService.GetDVCRImageName was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetDVCRImageName.Lock() + mock.calls.GetDVCRImageName = append(mock.calls.GetDVCRImageName, callInfo) + mock.lockGetDVCRImageName.Unlock() + return mock.GetDVCRImageNameFunc(pod) +} + +// GetDVCRImageNameCalls gets all the calls that were made to GetDVCRImageName. +// Check the length with: +// +// len(mockedRegistryDataSourceStatService.GetDVCRImageNameCalls()) +func (mock *RegistryDataSourceStatServiceMock) GetDVCRImageNameCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetDVCRImageName.RLock() + calls = mock.calls.GetDVCRImageName + mock.lockGetDVCRImageName.RUnlock() + return calls +} + +// GetDownloadSpeed calls GetDownloadSpeedFunc. +func (mock *RegistryDataSourceStatServiceMock) GetDownloadSpeed(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed { + if mock.GetDownloadSpeedFunc == nil { + panic("RegistryDataSourceStatServiceMock.GetDownloadSpeedFunc: method is nil but RegistryDataSourceStatService.GetDownloadSpeed was just called") + } + callInfo := struct { + OwnerUID types.UID + Pod *corev1.Pod + }{ + OwnerUID: ownerUID, + Pod: pod, + } + mock.lockGetDownloadSpeed.Lock() + mock.calls.GetDownloadSpeed = append(mock.calls.GetDownloadSpeed, callInfo) + mock.lockGetDownloadSpeed.Unlock() + return mock.GetDownloadSpeedFunc(ownerUID, pod) +} + +// GetDownloadSpeedCalls gets all the calls that were made to GetDownloadSpeed. +// Check the length with: +// +// len(mockedRegistryDataSourceStatService.GetDownloadSpeedCalls()) +func (mock *RegistryDataSourceStatServiceMock) GetDownloadSpeedCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod +} { + var calls []struct { + OwnerUID types.UID + Pod *corev1.Pod + } + mock.lockGetDownloadSpeed.RLock() + calls = mock.calls.GetDownloadSpeed + mock.lockGetDownloadSpeed.RUnlock() + return calls +} + +// GetFormat calls GetFormatFunc. +func (mock *RegistryDataSourceStatServiceMock) GetFormat(pod *corev1.Pod) string { + if mock.GetFormatFunc == nil { + panic("RegistryDataSourceStatServiceMock.GetFormatFunc: method is nil but RegistryDataSourceStatService.GetFormat was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetFormat.Lock() + mock.calls.GetFormat = append(mock.calls.GetFormat, callInfo) + mock.lockGetFormat.Unlock() + return mock.GetFormatFunc(pod) +} + +// GetFormatCalls gets all the calls that were made to GetFormat. +// Check the length with: +// +// len(mockedRegistryDataSourceStatService.GetFormatCalls()) +func (mock *RegistryDataSourceStatServiceMock) GetFormatCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetFormat.RLock() + calls = mock.calls.GetFormat + mock.lockGetFormat.RUnlock() + return calls +} + +// GetProgress calls GetProgressFunc. +func (mock *RegistryDataSourceStatServiceMock) GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string { + if mock.GetProgressFunc == nil { + panic("RegistryDataSourceStatServiceMock.GetProgressFunc: method is nil but RegistryDataSourceStatService.GetProgress was just called") + } + callInfo := struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption + }{ + OwnerUID: ownerUID, + Pod: pod, + PrevProgress: prevProgress, + Opts: opts, + } + mock.lockGetProgress.Lock() + mock.calls.GetProgress = append(mock.calls.GetProgress, callInfo) + mock.lockGetProgress.Unlock() + return mock.GetProgressFunc(ownerUID, pod, prevProgress, opts...) +} + +// GetProgressCalls gets all the calls that were made to GetProgress. +// Check the length with: +// +// len(mockedRegistryDataSourceStatService.GetProgressCalls()) +func (mock *RegistryDataSourceStatServiceMock) GetProgressCalls() []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption +} { + var calls []struct { + OwnerUID types.UID + Pod *corev1.Pod + PrevProgress string + Opts []service.GetProgressOption + } + mock.lockGetProgress.RLock() + calls = mock.calls.GetProgress + mock.lockGetProgress.RUnlock() + return calls +} + +// GetSize calls GetSizeFunc. +func (mock *RegistryDataSourceStatServiceMock) GetSize(pod *corev1.Pod) v1alpha2.ImageStatusSize { + if mock.GetSizeFunc == nil { + panic("RegistryDataSourceStatServiceMock.GetSizeFunc: method is nil but RegistryDataSourceStatService.GetSize was just called") + } + callInfo := struct { + Pod *corev1.Pod + }{ + Pod: pod, + } + mock.lockGetSize.Lock() + mock.calls.GetSize = append(mock.calls.GetSize, callInfo) + mock.lockGetSize.Unlock() + return mock.GetSizeFunc(pod) +} + +// GetSizeCalls gets all the calls that were made to GetSize. +// Check the length with: +// +// len(mockedRegistryDataSourceStatService.GetSizeCalls()) +func (mock *RegistryDataSourceStatServiceMock) GetSizeCalls() []struct { + Pod *corev1.Pod +} { + var calls []struct { + Pod *corev1.Pod + } + mock.lockGetSize.RLock() + calls = mock.calls.GetSize + mock.lockGetSize.RUnlock() return calls } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref.go index a3586326cd..deb32b63cc 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref.go @@ -40,14 +40,15 @@ type ObjectRefDataSource struct { func NewObjectRefDataSource( recorder eventrecord.EventRecorderLogger, + statService *service.StatService, diskService *service.DiskService, client client.Client, ) *ObjectRefDataSource { return &ObjectRefDataSource{ diskService: diskService, - vdSnapshotSyncer: NewObjectRefVirtualDiskSnapshot(recorder, diskService, client), - viSyncer: NewObjectRefVirtualImage(diskService, client), - cviSyncer: NewObjectRefClusterVirtualImage(diskService, client), + vdSnapshotSyncer: NewObjectRefVirtualDiskSnapshot(recorder, diskService, diskService.PersistentVolumeClaim(), client), + viSyncer: NewObjectRefVirtualImage(diskService, diskService.PersistentVolumeClaim(), statService, client), + cviSyncer: NewObjectRefClusterVirtualImage(diskService, diskService.PersistentVolumeClaim(), statService, client), } } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_cvi.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_cvi.go index bc4975dcef..3f0712efd8 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_cvi.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_cvi.go @@ -23,7 +23,6 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -38,15 +37,21 @@ import ( type ObjectRefClusterVirtualImage struct { diskService ObjectRefClusterVirtualImageDiskService + pvcService DataSourcePVCService + statService ObjectRefClusterVirtualImageStatService client client.Client } func NewObjectRefClusterVirtualImage( diskService ObjectRefClusterVirtualImageDiskService, + pvcService DataSourcePVCService, + statService ObjectRefClusterVirtualImageStatService, client client.Client, ) *ObjectRefClusterVirtualImage { return &ObjectRefClusterVirtualImage{ diskService: diskService, + pvcService: pvcService, + statService: statService, client: client, } } @@ -66,18 +71,11 @@ func (ds ObjectRefClusterVirtualImage) Sync(ctx context.Context, vd *v1alpha2.Vi return reconcile.Result{}, fmt.Errorf("fetch pvc: %w", err) } - dv, err := object.FetchObject(ctx, supgen.DataVolume(), ds.client, &cdiv1.DataVolume{}) - if err != nil { - return reconcile.Result{}, fmt.Errorf("fetch dv: %w", err) - } - return steptaker.NewStepTakers[*v1alpha2.VirtualDisk]( step.NewReadyStep(ds.diskService, pvc, cb), step.NewTerminatingStep(pvc), - step.NewCreateDataVolumeFromClusterVirtualImageStep(pvc, dv, ds.diskService, ds.client, cb), - step.NewEnsureNodePlacementStep(pvc, dv, ds.diskService, ds.client, cb), - step.NewWaitForDVStep(pvc, dv, ds.diskService, ds.client, cb), - step.NewWaitForPVCStep(pvc, ds.client, cb), + step.NewCreatePVCFromClusterVirtualImageStep(pvc, ds.diskService, ds.pvcService, ds.client, cb), + step.NewWaitForPVCImportStep(pvc, step.ClusterVirtualImagePVCImportSource(ds.client), ds.pvcService, ds.statService, nil, ds.client, cb), ).Run(ctx, vd) } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_cvi_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_cvi_test.go index bba5d04a43..8e01e0055a 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_cvi_test.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_cvi_test.go @@ -18,6 +18,7 @@ package source import ( "context" + "errors" "log/slog" . "github.com/onsi/ginkgo/v2" @@ -27,11 +28,13 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/client" + kclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" @@ -48,8 +51,9 @@ var _ = Describe("ObjectRef ClusterVirtualImage", func() { vd *v1alpha2.VirtualDisk sc *storagev1.StorageClass pvc *corev1.PersistentVolumeClaim - dv *cdiv1.DataVolume svc *ObjectRefVirtualImageDiskServiceMock + pvcSvc *DataSourcePVCServiceMock + stat *ObjectRefClusterVirtualImageStatServiceMock ) BeforeEach(func() { @@ -58,22 +62,34 @@ var _ = Describe("ObjectRef ClusterVirtualImage", func() { scheme = runtime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) Expect(corev1.AddToScheme(scheme)).To(Succeed()) - Expect(cdiv1.AddToScheme(scheme)).To(Succeed()) Expect(storagev1.AddToScheme(scheme)).To(Succeed()) - svc = &ObjectRefVirtualImageDiskServiceMock{ - GetProgressFunc: func(_ *cdiv1.DataVolume, _ string, _ ...service.GetProgressOption) string { - return "10%" + stat = &ObjectRefClusterVirtualImageStatServiceMock{ + GetProgressFunc: func(_ types.UID, _ *corev1.Pod, prev string, _ ...service.GetProgressOption) string { + return prev }, + } + + svc = &ObjectRefVirtualImageDiskServiceMock{ GetCapacityFunc: func(_ *corev1.PersistentVolumeClaim) string { return "100Mi" }, CleanUpSupplementsFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, - ProtectFunc: func(_ context.Context, _ supplements.Generator, _ client.Object, _ *cdiv1.DataVolume, _ *corev1.PersistentVolumeClaim) error { + GetVolumeAndAccessModesFunc: func(_ context.Context, _ kclient.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil + }, + } + + pvcSvc = &DataSourcePVCServiceMock{ + FinalizersFunc: func() []string { return nil }, + ImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { return nil }, + WaitForImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + return corev1.PodRunning, nil + }, } sc = &storagev1.StorageClass{ @@ -134,88 +150,93 @@ var _ = Describe("ObjectRef ClusterVirtualImage", func() { }, }, } - - dv = &cdiv1.DataVolume{ - ObjectMeta: metav1.ObjectMeta{ - Name: supgen.DataVolume().Name, - Namespace: vd.Namespace, - }, - Status: cdiv1.DataVolumeStatus{ - ClaimName: pvc.Name, - }, - } }) Context("VirtualDisk has just been created", func() { - It("must create DataVolume", func() { - var dvCreated bool + It("must start PVC import", func() { + var importStarted bool vd.Status = v1alpha2.VirtualDiskStatus{ + StorageClassName: sc.Name, Target: v1alpha2.DiskTarget{ PersistentVolumeClaim: "test-pvc", }, } fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cvi, sc).Build() - svc.StartFunc = func(_ context.Context, _ resource.Quantity, _ *storagev1.StorageClass, _ *cdiv1.DataVolumeSource, _ client.Object, _ supplements.DataVolumeSupplement, _ ...service.Option) error { - dvCreated = true - return nil + pvcSvc.CreateTargetFromDVCRFunc = func(_ context.Context, _ types.NamespacedName, _ string, _ *resource.Quantity, _ kclient.Object, _ *service.PVCImportSourceRegistry, _ service.VolumeAndAccessModesGetter, _ *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + importStarted = true + return corev1.PersistentVolumeClaim{}, nil } - syncer := NewObjectRefClusterVirtualImage(svc, fakeClient) + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, fakeClient) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) Expect(res.IsZero()).To(BeTrue()) - Expect(dvCreated).To(BeTrue()) + Expect(importStarted).To(BeTrue()) ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) Expect(vd.Status.Progress).ToNot(BeEmpty()) Expect(vd.Status.Target.PersistentVolumeClaim).ToNot(BeEmpty()) }) - }) - Context("VirtualDisk waits for the PVC to be Bound", func() { - BeforeEach(func() { - svc.CheckProvisioningFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim) error { - return nil + It("propagates a target PVC quota rejection as Pending/QuotaExceeded instead of an error", func() { + vd.Status = v1alpha2.VirtualDiskStatus{ + StorageClassName: sc.Name, + Target: v1alpha2.DiskTarget{ + PersistentVolumeClaim: "test-pvc", + }, } + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cvi, sc).Build() + pvcSvc.CreateTargetFromDVCRFunc = func(_ context.Context, _ types.NamespacedName, _ string, _ *resource.Quantity, _ kclient.Object, _ *service.PVCImportSourceRegistry, _ service.VolumeAndAccessModesGetter, _ *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + return corev1.PersistentVolumeClaim{}, errors.New(`persistentvolumeclaims "d8v-vd-test" is forbidden: exceeded quota: block-pods-and-pvcs, requested: count/persistentvolumeclaims=1, used: count/persistentvolumeclaims=1, limited: count/persistentvolumeclaims=0`) + } + + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, fakeClient) + + res, err := syncer.Sync(ctx, vd) + // The quota rejection must not surface as a reconciler error. + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskPending)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.QuotaExceeded, true) }) + }) - It("waits for the first consumer", func() { - dv.Status.Phase = cdiv1.PendingPopulation - dv.Status.Conditions = []cdiv1.DataVolumeCondition{ - { - Type: cdiv1.DataVolumeRunning, - Status: corev1.ConditionFalse, - Reason: "", - }, - } + Context("VirtualDisk is provisioning while its PVC is not yet Bound", func() { + // With the prime/rebind import flow the importer fills a separate prime PVC + // and the target PVC only becomes Bound at the very end (via rebind), so a + // Pending target means the import is in progress, regardless of the storage + // class binding mode. + It("reports Provisioning for WFFC storage class", func() { + pvc.Status.Phase = corev1.ClaimPending sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingWaitForFirstConsumer) - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, dv, sc).Build() + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() - syncer := NewObjectRefClusterVirtualImage(svc, client) + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) - Expect(res.IsZero()).To(BeTrue()) + Expect(res.RequeueAfter).ToNot(BeZero()) - ExpectCondition(vd, metav1.ConditionFalse, vdcondition.WaitingForFirstConsumer, true) - Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskWaitForFirstConsumer)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) Expect(vd.Status.Progress).ToNot(BeEmpty()) Expect(vd.Status.Target.PersistentVolumeClaim).ToNot(BeEmpty()) }) - It("is in provisioning", func() { + It("reports Provisioning for Immediate storage class", func() { pvc.Status.Phase = corev1.ClaimPending sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingImmediate) - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, dv, sc).Build() + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() - syncer := NewObjectRefClusterVirtualImage(svc, client) + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) - Expect(res.IsZero()).To(BeTrue()) + Expect(res.RequeueAfter).ToNot(BeZero()) ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) @@ -226,11 +247,10 @@ var _ = Describe("ObjectRef ClusterVirtualImage", func() { Context("VirtualDisk is ready", func() { It("checks that the VirtualDisk is ready", func() { - dv.Status.Phase = cdiv1.Succeeded pvc.Status.Phase = corev1.ClaimBound - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(dv, pvc).Build() + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() - syncer := NewObjectRefClusterVirtualImage(svc, client) + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -240,6 +260,51 @@ var _ = Describe("ObjectRef ClusterVirtualImage", func() { Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskReady)) ExpectStats(vd) }) + + It("requeues when the import has just completed", func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationStrategy: service.PopulationStrategyDVCR} + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, cvi).Build() + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + return corev1.PodSucceeded, nil + } + + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, client) + + res, err := syncer.Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + }) + + It("waits for populator when the target PVC already exists", func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationStrategy: service.PopulationStrategyDVCR} + cvi.Status.Target.RegistryURL = "registry.example/cvi/source" + var imported bool + pvcSvc.ImportFunc = func(_ context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { + imported = true + Expect(target.Name).To(Equal(pvc.Name)) + Expect(source).ToNot(BeNil()) + Expect(source.Registry).ToNot(BeNil()) + return nil + } + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + Expect(imported).To(BeTrue()) + Expect(source).ToNot(BeNil()) + Expect(source.Registry).ToNot(BeNil()) + return corev1.PodPending, nil + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, cvi).Build() + + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, client) + + res, err := syncer.Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + Expect(imported).To(BeFalse()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) }) Context("VirtualDisk is lost", func() { @@ -258,7 +323,7 @@ var _ = Describe("ObjectRef ClusterVirtualImage", func() { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects().Build() - syncer := NewObjectRefClusterVirtualImage(svc, client) + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -274,7 +339,7 @@ var _ = Describe("ObjectRef ClusterVirtualImage", func() { vd.Status.Target.PersistentVolumeClaim = pvc.Name client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() - syncer := NewObjectRefClusterVirtualImage(svc, client) + syncer := NewObjectRefClusterVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vdsnapshot.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vdsnapshot.go index 66f210f75e..46a25d080f 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vdsnapshot.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vdsnapshot.go @@ -38,13 +38,15 @@ import ( type ObjectRefVirtualDiskSnapshot struct { diskService ObjectRefVirtualDiskSnapshotDiskService + pvcService DataSourcePVCService recorder eventrecord.EventRecorderLogger client client.Client } -func NewObjectRefVirtualDiskSnapshot(recorder eventrecord.EventRecorderLogger, diskService ObjectRefVirtualDiskSnapshotDiskService, client client.Client) *ObjectRefVirtualDiskSnapshot { +func NewObjectRefVirtualDiskSnapshot(recorder eventrecord.EventRecorderLogger, diskService ObjectRefVirtualDiskSnapshotDiskService, pvcService DataSourcePVCService, client client.Client) *ObjectRefVirtualDiskSnapshot { return &ObjectRefVirtualDiskSnapshot{ diskService: diskService, + pvcService: pvcService, recorder: recorder, client: client, } @@ -69,7 +71,7 @@ func (ds ObjectRefVirtualDiskSnapshot) Sync(ctx context.Context, vd *v1alpha2.Vi return steptaker.NewStepTakers[*v1alpha2.VirtualDisk]( step.NewReadyStep(ds.diskService, pvc, cb), step.NewTerminatingStep(pvc), - step.NewCreatePVCFromVDSnapshotStep(pvc, ds.recorder, ds.client, cb), + step.NewCreatePVCFromVDSnapshotStep(pvc, ds.diskService, ds.pvcService, ds.recorder, ds.client, cb), step.NewWaitForPVCStep(pvc, ds.client, cb), ).Run(ctx, vd) } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vdsnapshot_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vdsnapshot_test.go index 446aedc7ae..f1629ad643 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vdsnapshot_test.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vdsnapshot_test.go @@ -30,13 +30,13 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + ctrlservice "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" @@ -82,8 +82,8 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { CleanUpSupplementsFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, - ProtectFunc: func(_ context.Context, _ supplements.Generator, _ client.Object, _ *cdiv1.DataVolume, _ *corev1.PersistentVolumeClaim) error { - return nil + GetVolumeAndAccessModesFunc: func(_ context.Context, _ client.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil }, } @@ -108,6 +108,9 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { vs = &vsv1.VolumeSnapshot{ ObjectMeta: metav1.ObjectMeta{ Name: "vs", + Annotations: map[string]string{ + annotations.AnnStorageClassName: sc.Name, + }, }, Status: &vsv1.VolumeSnapshotStatus{ ReadyToUse: ptr.To(true), @@ -157,7 +160,7 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { PersistentVolumeClaim: "test-pvc", }, } - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs). + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs, sc). WithInterceptorFuncs(interceptor.Funcs{ Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { _, ok := obj.(*corev1.PersistentVolumeClaim) @@ -167,7 +170,7 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { }, }).Build() - syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, client) + syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, testVDPVCService(client), client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -188,15 +191,16 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { vd.Spec.PersistentVolumeClaim.Size = ptr.To(resource.MustParse(vdSize)) } if originalSize != "" { - vs.Annotations = map[string]string{ - annotations.AnnVirtualDiskOriginalSize: originalSize, + if vs.Annotations == nil { + vs.Annotations = map[string]string{} } + vs.Annotations[annotations.AnnVirtualDiskOriginalSize] = originalSize } if restoreSize != "" { vs.Status.RestoreSize = ptr.To(resource.MustParse(restoreSize)) } - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs). + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs, sc). WithInterceptorFuncs(interceptor.Funcs{ Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { pvc, ok := obj.(*corev1.PersistentVolumeClaim) @@ -207,7 +211,7 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { }, }).Build() - syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, client) + syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, testVDPVCService(client), client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -225,7 +229,7 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingWaitForFirstConsumer) client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc, vdSnapshot, vs).Build() - syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, client) + syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, testVDPVCService(client), client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -240,7 +244,7 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingImmediate) client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc, vdSnapshot, vs).Build() - syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, client) + syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, testVDPVCService(client), client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -256,7 +260,7 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { pvc.Status.Phase = corev1.ClaimBound client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() - syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, client) + syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, testVDPVCService(client), client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -284,7 +288,7 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects().Build() - syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, client) + syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, testVDPVCService(client), client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -300,7 +304,7 @@ var _ = Describe("ObjectRef VirtualDiskSnapshot", func() { vd.Status.Target.PersistentVolumeClaim = pvc.Name client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() - syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, client) + syncer := NewObjectRefVirtualDiskSnapshot(recorder, svc, testVDPVCService(client), client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -332,3 +336,7 @@ func ExpectCondition(vd *v1alpha2.VirtualDisk, status metav1.ConditionStatus, re Expect(ready.Message).To(BeEmpty()) } } + +func testVDPVCService(c client.Client) *ctrlservice.PersistentVolumeClaimService { + return ctrlservice.NewPersistentVolumeClaimService(c, nil, nil, ctrlservice.DiskImporterConfig{}) +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vi.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vi.go index ecf5891a25..c1b552806d 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vi.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vi.go @@ -23,7 +23,6 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -38,15 +37,21 @@ import ( type ObjectRefVirtualImage struct { diskService ObjectRefVirtualImageDiskService + pvcService DataSourcePVCService + statService ObjectRefVirtualImageStatService client client.Client } func NewObjectRefVirtualImage( diskService ObjectRefVirtualImageDiskService, + pvcService DataSourcePVCService, + statService ObjectRefVirtualImageStatService, client client.Client, ) *ObjectRefVirtualImage { return &ObjectRefVirtualImage{ diskService: diskService, + pvcService: pvcService, + statService: statService, client: client, } } @@ -66,18 +71,11 @@ func (ds ObjectRefVirtualImage) Sync(ctx context.Context, vd *v1alpha2.VirtualDi return reconcile.Result{}, fmt.Errorf("fetch pvc: %w", err) } - dv, err := object.FetchObject(ctx, supgen.DataVolume(), ds.client, &cdiv1.DataVolume{}) - if err != nil { - return reconcile.Result{}, fmt.Errorf("fetch dv: %w", err) - } - return steptaker.NewStepTakers[*v1alpha2.VirtualDisk]( step.NewReadyStep(ds.diskService, pvc, cb), step.NewTerminatingStep(pvc), - step.NewCreateDataVolumeFromVirtualImageStep(pvc, dv, ds.diskService, ds.client, cb), - step.NewEnsureNodePlacementStep(pvc, dv, ds.diskService, ds.client, cb), - step.NewWaitForDVStep(pvc, dv, ds.diskService, ds.client, cb), - step.NewWaitForPVCStep(pvc, ds.client, cb), + step.NewCreatePVCFromVirtualImageStep(pvc, ds.diskService, ds.pvcService, ds.client, cb), + step.NewWaitForPVCImportStep(pvc, step.VirtualImagePVCImportSource(ds.client), ds.pvcService, ds.statService, nil, ds.client, cb), ).Run(ctx, vd) } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vi_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vi_test.go index 5eacabf594..68440105f3 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vi_test.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/object_ref_vi_test.go @@ -27,11 +27,13 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/client" + kclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" @@ -48,8 +50,9 @@ var _ = Describe("ObjectRef VirtualImage", func() { vd *v1alpha2.VirtualDisk sc *storagev1.StorageClass pvc *corev1.PersistentVolumeClaim - dv *cdiv1.DataVolume svc *ObjectRefVirtualImageDiskServiceMock + pvcSvc *DataSourcePVCServiceMock + stat *ObjectRefVirtualImageStatServiceMock ) BeforeEach(func() { @@ -58,22 +61,34 @@ var _ = Describe("ObjectRef VirtualImage", func() { scheme = runtime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) Expect(corev1.AddToScheme(scheme)).To(Succeed()) - Expect(cdiv1.AddToScheme(scheme)).To(Succeed()) Expect(storagev1.AddToScheme(scheme)).To(Succeed()) - svc = &ObjectRefVirtualImageDiskServiceMock{ - GetProgressFunc: func(_ *cdiv1.DataVolume, _ string, _ ...service.GetProgressOption) string { - return "10%" + stat = &ObjectRefVirtualImageStatServiceMock{ + GetProgressFunc: func(_ types.UID, _ *corev1.Pod, prev string, _ ...service.GetProgressOption) string { + return prev }, + } + + svc = &ObjectRefVirtualImageDiskServiceMock{ GetCapacityFunc: func(_ *corev1.PersistentVolumeClaim) string { return "100Mi" }, CleanUpSupplementsFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, - ProtectFunc: func(_ context.Context, _ supplements.Generator, _ client.Object, _ *cdiv1.DataVolume, _ *corev1.PersistentVolumeClaim) error { + GetVolumeAndAccessModesFunc: func(_ context.Context, _ kclient.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil + }, + } + + pvcSvc = &DataSourcePVCServiceMock{ + FinalizersFunc: func() []string { return nil }, + ImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { return nil }, + WaitForImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + return corev1.PodRunning, nil + }, } sc = &storagev1.StorageClass{ @@ -134,39 +149,30 @@ var _ = Describe("ObjectRef VirtualImage", func() { }, }, } - - dv = &cdiv1.DataVolume{ - ObjectMeta: metav1.ObjectMeta{ - Name: supgen.DataVolume().Name, - Namespace: vd.Namespace, - }, - Status: cdiv1.DataVolumeStatus{ - ClaimName: pvc.Name, - }, - } }) Context("VirtualDisk has just been created", func() { - It("must create DataVolume", func() { - var dvCreated bool + It("must start PVC import", func() { + var importStarted bool vd.Status = v1alpha2.VirtualDiskStatus{ + StorageClassName: sc.Name, Target: v1alpha2.DiskTarget{ PersistentVolumeClaim: "test-pvc", }, } fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vi, sc).Build() - svc.StartFunc = func(_ context.Context, _ resource.Quantity, _ *storagev1.StorageClass, _ *cdiv1.DataVolumeSource, _ client.Object, _ supplements.DataVolumeSupplement, _ ...service.Option) error { - dvCreated = true - return nil + pvcSvc.CreateTargetFromDVCRFunc = func(_ context.Context, _ types.NamespacedName, _ string, _ *resource.Quantity, _ kclient.Object, _ *service.PVCImportSourceRegistry, _ service.VolumeAndAccessModesGetter, _ *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + importStarted = true + return corev1.PersistentVolumeClaim{}, nil } - syncer := NewObjectRefVirtualImage(svc, fakeClient) + syncer := NewObjectRefVirtualImage(svc, pvcSvc, stat, fakeClient) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) Expect(res.IsZero()).To(BeTrue()) - Expect(dvCreated).To(BeTrue()) + Expect(importStarted).To(BeTrue()) ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) @@ -175,47 +181,38 @@ var _ = Describe("ObjectRef VirtualImage", func() { }) }) - Context("VirtualDisk waits for the PVC to be Bound", func() { - BeforeEach(func() { - svc.CheckProvisioningFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim) error { - return nil - } - }) - - It("waits for the first consumer", func() { - dv.Status.Phase = cdiv1.PendingPopulation - dv.Status.Conditions = []cdiv1.DataVolumeCondition{ - { - Type: cdiv1.DataVolumeRunning, - Status: corev1.ConditionFalse, - Reason: "", - }, - } + Context("VirtualDisk is provisioning while its PVC is not yet Bound", func() { + // With the prime/rebind import flow the importer fills a separate prime PVC + // and the target PVC only becomes Bound at the very end (via rebind), so a + // Pending target means the import is in progress, regardless of the storage + // class binding mode. + It("reports Provisioning for WFFC storage class", func() { + pvc.Status.Phase = corev1.ClaimPending sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingWaitForFirstConsumer) - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, dv, sc).Build() + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() - syncer := NewObjectRefVirtualImage(svc, client) + syncer := NewObjectRefVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) - Expect(res.IsZero()).To(BeTrue()) + Expect(res.RequeueAfter).ToNot(BeZero()) - ExpectCondition(vd, metav1.ConditionFalse, vdcondition.WaitingForFirstConsumer, true) - Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskWaitForFirstConsumer)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) Expect(vd.Status.Progress).ToNot(BeEmpty()) Expect(vd.Status.Target.PersistentVolumeClaim).ToNot(BeEmpty()) }) - It("is in provisioning", func() { + It("reports Provisioning for Immediate storage class", func() { pvc.Status.Phase = corev1.ClaimPending sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingImmediate) - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, dv, sc).Build() + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() - syncer := NewObjectRefVirtualImage(svc, client) + syncer := NewObjectRefVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) - Expect(res.IsZero()).To(BeTrue()) + Expect(res.RequeueAfter).ToNot(BeZero()) ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) @@ -226,11 +223,10 @@ var _ = Describe("ObjectRef VirtualImage", func() { Context("VirtualDisk is ready", func() { It("checks that the VirtualDisk is ready", func() { - dv.Status.Phase = cdiv1.Succeeded pvc.Status.Phase = corev1.ClaimBound - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(dv, pvc).Build() + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() - syncer := NewObjectRefVirtualImage(svc, client) + syncer := NewObjectRefVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -240,6 +236,52 @@ var _ = Describe("ObjectRef VirtualImage", func() { Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskReady)) ExpectStats(vd) }) + + It("requeues when the import has just completed", func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationStrategy: service.PopulationStrategyDVCR} + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, vi).Build() + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + return corev1.PodSucceeded, nil + } + + syncer := NewObjectRefVirtualImage(svc, pvcSvc, stat, client) + + res, err := syncer.Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + }) + + It("resumes by starting the PVC import when the target PVC already exists", func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationStrategy: service.PopulationStrategyDVCR} + vi.Spec.Storage = v1alpha2.StoragePersistentVolumeClaim + vi.Status.Target.PersistentVolumeClaim = "source-pvc" + var imported bool + pvcSvc.ImportFunc = func(_ context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { + imported = true + Expect(target.Name).To(Equal(pvc.Name)) + Expect(source).ToNot(BeNil()) + Expect(source.PVC).ToNot(BeNil()) + return nil + } + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ kclient.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + Expect(imported).To(BeTrue()) + Expect(source).ToNot(BeNil()) + Expect(source.PVC).ToNot(BeNil()) + return corev1.PodPending, nil + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, vi).Build() + + syncer := NewObjectRefVirtualImage(svc, pvcSvc, stat, client) + + res, err := syncer.Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + Expect(imported).To(BeFalse()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) }) Context("VirtualDisk is lost", func() { @@ -258,7 +300,7 @@ var _ = Describe("ObjectRef VirtualImage", func() { } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects().Build() - syncer := NewObjectRefVirtualImage(svc, client) + syncer := NewObjectRefVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) @@ -274,7 +316,7 @@ var _ = Describe("ObjectRef VirtualImage", func() { vd.Status.Target.PersistentVolumeClaim = pvc.Name client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() - syncer := NewObjectRefVirtualImage(svc, client) + syncer := NewObjectRefVirtualImage(svc, pvcSvc, stat, client) res, err := syncer.Sync(ctx, vd) Expect(err).ToNot(HaveOccurred()) diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/registry.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/registry.go index 2c2bf4119c..9fbcca72dc 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/registry.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/registry.go @@ -20,28 +20,20 @@ import ( "context" "errors" "fmt" - "time" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/virtualization-controller/pkg/common" "github.com/deckhouse/virtualization-controller/pkg/common/datasource" - "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" "github.com/deckhouse/virtualization-controller/pkg/common/object" - podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" - "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/common/steptaker" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/importer" "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source/step" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" "github.com/deckhouse/virtualization-controller/pkg/dvcr" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -53,9 +45,10 @@ import ( const registryDataSource = "registry" type RegistryDataSource struct { - statService *service.StatService - importerService *service.ImporterService - diskService *service.DiskService + statService RegistryDataSourceStatService + importerService RegistryDataSourceImporterService + diskService RegistryDataSourceDiskService + pvcService DataSourcePVCService dvcrSettings *dvcr.Settings client client.Client recorder eventrecord.EventRecorderLogger @@ -63,9 +56,10 @@ type RegistryDataSource struct { func NewRegistryDataSource( recorder eventrecord.EventRecorderLogger, - statService *service.StatService, - importerService *service.ImporterService, - diskService *service.DiskService, + statService RegistryDataSourceStatService, + importerService RegistryDataSourceImporterService, + diskService RegistryDataSourceDiskService, + pvcService DataSourcePVCService, dvcrSettings *dvcr.Settings, client client.Client, ) *RegistryDataSource { @@ -73,6 +67,7 @@ func NewRegistryDataSource( statService: statService, importerService: importerService, diskService: diskService, + pvcService: pvcService, dvcrSettings: dvcrSettings, client: client, recorder: recorder, @@ -82,256 +77,33 @@ func NewRegistryDataSource( func (ds RegistryDataSource) Sync(ctx context.Context, vd *v1alpha2.VirtualDisk) (reconcile.Result, error) { log, ctx := logger.GetDataSourceContext(ctx, registryDataSource) - condition, _ := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions) + supgen := vdsupplements.NewGenerator(vd) + cb := conditions.NewConditionBuilder(vdcondition.ReadyType).Generation(vd.Generation) defer func() { conditions.SetCondition(cb, &vd.Status.Conditions) }() - supgen := vdsupplements.NewGenerator(vd) - pod, err := ds.importerService.GetPod(ctx, supgen) if err != nil { - return reconcile.Result{}, err - } - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) - vdsupplements.SetPVCName(vd, dv.Status.ClaimName) + return reconcile.Result{}, fmt.Errorf("fetch importer pod: %w", err) } - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vd.Status.StorageClassName) + pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { - return reconcile.Result{}, err + return reconcile.Result{}, fmt.Errorf("fetch pvc: %w", err) } - - switch { - case IsDiskProvisioningFinished(condition): - log.Debug("Disk provisioning finished: clean up") - - setPhaseConditionForFinishedDisk(vd, pvc, cb, &vd.Status.Phase, supgen) - - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen, vd, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - - // Unprotect import time supplements to delete them later. - err = ds.importerService.Unprotect(ctx, pod, supgen) - if err != nil { - return reconcile.Result{}, err - } - - err = ds.diskService.Unprotect(ctx, supgen, dv) - if err != nil { - return reconcile.Result{}, err - } - - return CleanUpSupplements(ctx, vd, ds) - case object.AnyTerminating(pod, dv, pvc): - log.Info("Waiting for supplements to be terminated") - case pod == nil: - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncStarted, - "The Registry DataSource import to DVCR has started", - ) - - vd.Status.Progress = "0%" - - envSettings := ds.getEnvSettings(vd, supgen) - - err = ds.importerService.Start( - ctx, envSettings, vd, supgen, - datasource.NewCABundleForVMD(vd.GetNamespace(), vd.Spec.DataSource), - service.WithSystemNodeToleration(), - ) - switch { - case err == nil: - // OK. - case common.ErrQuotaExceeded(err): - ds.recorder.Event(vd, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceQuotaExceeded, "DataSource quota exceed") - return setQuotaExceededPhaseCondition(vd, cb, &vd.Status.Phase, err, vd.CreationTimestamp), nil - default: - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return reconcile.Result{}, err - } - - vd.Status.Phase = v1alpha2.DiskPending - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.WaitForUserUpload, "DVCR Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case !podutil.IsPodComplete(pod): - log.Info("Provisioning to DVCR is in progress", "podPhase", pod.Status.Phase) - - err = ds.statService.CheckPod(pod) - if err != nil { - return reconcile.Result{}, setPhaseConditionFromPodError(ctx, err, pod, vd, cb, ds.client) - } - - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "DVCR Provisioner not found: create the new one.") - - vd.Status.Progress = ds.statService.GetProgress(vd.GetUID(), pod, vd.Status.Progress, service.NewScaleOption(0, 50)) - - err = ds.importerService.Protect(ctx, pod, supgen) - if err != nil { - return reconcile.Result{}, err - } - case dv == nil: - if isStorageClassWFFC(sc) && len(vd.Status.AttachedToVirtualMachines) != 1 { - vd.Status.Progress = "50%" - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - setReadyConditionWithWFFCAccounting( - vd, - cb, - metav1.ConditionFalse, - vdcondition.WaitingForFirstConsumer, - "The provisioning has been suspended: a created and scheduled virtual machine is awaited.", - ) - return reconcile.Result{}, nil - } - - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncStarted, - "The Registry DataSource import to PVC has started", - ) - - err = ds.statService.CheckPod(pod) - if err != nil { - vd.Status.Phase = v1alpha2.DiskFailed - - switch { - case errors.Is(err, service.ErrProvisioningFailed): - ds.recorder.Event(vd, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceDiskProvisioningFailed, "Disk provisioning failed") - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningFailed, service.CapitalizeFirstLetter(err.Error()+".")) - return reconcile.Result{}, nil - default: - return reconcile.Result{}, err - } - } - - vd.Status.Progress = "50%" - - if imageformat.IsISO(ds.statService.GetFormat(pod)) { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, ErrISOSourceNotSupported) - return reconcile.Result{}, nil - } - - var diskSize resource.Quantity - diskSize, err = ds.getPVCSize(vd, pod) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, err) - - if errors.Is(err, service.ErrInsufficientPVCSize) { - return reconcile.Result{}, nil - } - - return reconcile.Result{}, err - } - - source := ds.getSource(supgen, ds.statService.GetDVCRImageName(pod)) - - var nodePlacement *provisioner.NodePlacement - nodePlacement, err = getNodePlacement(ctx, ds.client, vd) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return reconcile.Result{}, fmt.Errorf("failed to get importer tolerations: %w", err) - } - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vd.Status.StorageClassName) - if err != nil { - return reconcile.Result{}, err - } - - err = ds.diskService.Start(ctx, diskSize, sc, source, vd, supgen, service.WithNodePlacement(nodePlacement)) - if updated, err := setPhaseConditionFromStorageError(err, vd, cb); err != nil || updated { - return reconcile.Result{}, err - } - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "PVC Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vd.Status.Phase = v1alpha2.DiskPending - if dv.Status.ClaimName != "" && isStorageClassWFFC(sc) { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - } - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.QuotaExceeded, dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vd.Status.Phase = v1alpha2.DiskPending - if dv.Status.ClaimName != "" && isStorageClassWFFC(sc) { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - } - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ImagePullFailed, dvRunningCondition.Message) - ds.recorder.Event(vd, corev1.EventTypeWarning, vdcondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncCompleted, - "The Registry DataSource import has completed", - ) - - vd.Status.Phase = v1alpha2.DiskReady - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionTrue, vdcondition.Ready, "") - - vd.Status.Progress = "100%" - vd.Status.Capacity = ds.diskService.GetCapacity(pvc) - vdsupplements.SetPVCName(vd, dv.Status.ClaimName) - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - err = ds.diskService.CheckProvisioning(ctx, pvc) - if err != nil { - return reconcile.Result{}, setPhaseConditionFromProvisioningError(ctx, err, cb, vd, dv, ds.diskService, ds.client) - } - - vd.Status.Progress = ds.diskService.GetProgress(dv, vd.Status.Progress, service.NewScaleOption(50, 100)) - vd.Status.Capacity = ds.diskService.GetCapacity(pvc) - vdsupplements.SetPVCName(vd, dv.Status.ClaimName) - - err = ds.diskService.Protect(ctx, supgen, vd, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, ptr.Deref(pvc.Spec.StorageClassName, "")) - if err != nil { - return reconcile.Result{}, err - } - - if err = setPhaseConditionForPVCProvisioningDisk(ctx, dv, vd, pvc, sc, cb, ds.diskService); err != nil { - return reconcile.Result{}, err - } - return reconcile.Result{}, nil + if pvc != nil { + ctx = logger.ToContext(ctx, log.With("pvc.name", pvc.Name, "pvc.status.phase", pvc.Status.Phase)) } - return reconcile.Result{RequeueAfter: time.Second}, nil + return steptaker.NewStepTakers[*v1alpha2.VirtualDisk]( + step.NewCleanUpImporterStep(pod, ds.importerService), + step.NewReadyStep(ds.diskService, pvc, cb), + step.NewTerminatingStep(pvc), + step.NewCreateImporterStep(pvc, pod, ds.buildEnvSettings, ds.importerService, ds.recorder, cb, "The Registry DataSource import to DVCR has started"), + step.NewWaitForDVCRImporterStep(pod, ds.statService, ds.importerService, ds.client, cb), + step.NewCreatePVCFromDVCRStep(pvc, pod, ds.statService, ds.diskService, ds.pvcService, ds.client, cb), + step.NewWaitForPVCImportStep(pvc, step.DVCRPodPVCImportSource(pod, ds.statService), ds.pvcService, ds.statService, service.NewScaleOption(50, 100), ds.client, cb), + ).Run(ctx, vd) } func (ds RegistryDataSource) CleanUp(ctx context.Context, vd *v1alpha2.VirtualDisk) (bool, error) { @@ -339,37 +111,17 @@ func (ds RegistryDataSource) CleanUp(ctx context.Context, vd *v1alpha2.VirtualDi importerRequeue, err := ds.importerService.CleanUp(ctx, supgen) if err != nil { - return false, err + return false, fmt.Errorf("clean up importer: %w", err) } diskRequeue, err := ds.diskService.CleanUp(ctx, supgen) if err != nil { - return false, err + return false, fmt.Errorf("clean up disk: %w", err) } return importerRequeue || diskRequeue, nil } -func (ds RegistryDataSource) CleanUpSupplements(ctx context.Context, vd *v1alpha2.VirtualDisk) (reconcile.Result, error) { - supgen := vdsupplements.NewGenerator(vd) - - importerRequeue, err := ds.importerService.CleanUpSupplements(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - - diskRequeue, err := ds.diskService.CleanUpSupplements(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - - if importerRequeue || diskRequeue { - return reconcile.Result{RequeueAfter: time.Second}, nil - } else { - return reconcile.Result{}, nil - } -} - func (ds RegistryDataSource) Validate(ctx context.Context, vd *v1alpha2.VirtualDisk) error { if vd.Spec.DataSource == nil || vd.Spec.DataSource.ContainerImage == nil { return errors.New("container image missed for data source") @@ -397,7 +149,7 @@ func (ds RegistryDataSource) Name() string { return registryDataSource } -func (ds RegistryDataSource) getEnvSettings(vd *v1alpha2.VirtualDisk, supgen supplements.Generator) *importer.Settings { +func (ds RegistryDataSource) buildEnvSettings(vd *v1alpha2.VirtualDisk, supgen supplements.Generator) *importer.Settings { var settings importer.Settings containerImage := &datasource.ContainerRegistry{ @@ -417,30 +169,3 @@ func (ds RegistryDataSource) getEnvSettings(vd *v1alpha2.VirtualDisk, supgen sup return &settings } - -func (ds RegistryDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *cdiv1.DataVolumeSource { - // The image was preloaded from source into dvcr. - // We can't use the same data source a second time, but we can set dvcr as the data source. - // Use DV name for the Secret with DVCR auth and the ConfigMap with DVCR CA Bundle. - url := common.DockerRegistrySchemePrefix + dvcrSourceImageName - secretName := sup.DVCRAuthSecretForDV().Name - certConfigMapName := sup.DVCRCABundleConfigMapForDV().Name - - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - } -} - -func (ds RegistryDataSource) getPVCSize(vd *v1alpha2.VirtualDisk, pod *corev1.Pod) (resource.Quantity, error) { - // Get size from the importer Pod to detect if specified PVC size is enough. - unpackedSize, err := resource.ParseQuantity(ds.statService.GetSize(pod).UnpackedBytes) - if err != nil { - return resource.Quantity{}, fmt.Errorf("failed to parse unpacked bytes %s: %w", ds.statService.GetSize(pod).UnpackedBytes, err) - } - - return service.GetValidatedPVCSize(vd.Spec.PersistentVolumeClaim.Size, unpackedSize) -} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/registry_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/registry_test.go new file mode 100644 index 0000000000..f22b51230c --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/registry_test.go @@ -0,0 +1,397 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "errors" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/datasource" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/controller/importer" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +var _ = Describe("RegistryDataSource", func() { + var ( + ctx context.Context + scheme *runtime.Scheme + vd *v1alpha2.VirtualDisk + sc *storagev1.StorageClass + pvc *corev1.PersistentVolumeClaim + disk *RegistryDataSourceDiskServiceMock + pvcSvc *DataSourcePVCServiceMock + importerSvc *RegistryDataSourceImporterServiceMock + stat *RegistryDataSourceStatServiceMock + recorder eventrecord.EventRecorderLogger + dvcrSettings *dvcr.Settings + ) + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + + scheme = runtime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(storagev1.AddToScheme(scheme)).To(Succeed()) + + recorder = &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + + dvcrSettings = &dvcr.Settings{ + RegistryURL: "dvcr.example.com", + } + + sc = &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "sc"}, + } + + vd = &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vd", + Generation: 1, + UID: "33333333-3333-3333-3333-333333333333", + }, + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeContainerImage, + ContainerImage: &v1alpha2.VirtualDiskContainerImage{ + Image: "registry.example.com/images/slackware:15", + }, + }, + }, + Status: v1alpha2.VirtualDiskStatus{ + StorageClassName: sc.Name, + Target: v1alpha2.DiskTarget{PersistentVolumeClaim: "test-pvc"}, + }, + } + + supgen := vdsupplements.NewGenerator(vd) + pvc = &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: supgen.PersistentVolumeClaim().Name, + Namespace: vd.Namespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr.To(sc.Name)}, + Status: corev1.PersistentVolumeClaimStatus{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}, + }, + } + + disk = &RegistryDataSourceDiskServiceMock{ + GetCapacityFunc: func(_ *corev1.PersistentVolumeClaim) string { return "1Gi" }, + GetPersistentVolumeClaimFunc: func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return pvc, nil + }, + CleanUpFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + CleanUpSupplementsFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + GetVolumeAndAccessModesFunc: func(_ context.Context, _ client.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil + }, + } + + pvcSvc = &DataSourcePVCServiceMock{ + FinalizersFunc: func() []string { return nil }, + ImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { + return nil + }, + WaitForImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + return corev1.PodRunning, nil + }, + } + + importerSvc = &RegistryDataSourceImporterServiceMock{ + GetPodFunc: func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return nil, nil }, + CleanUpFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + ProtectFunc: func(_ context.Context, _ *corev1.Pod, _ supplements.Generator) error { return nil }, + } + + stat = &RegistryDataSourceStatServiceMock{ + GetDVCRImageNameFunc: func(_ *corev1.Pod) string { return "dvcr.example.com/cvi/vd:1" }, + GetSizeFunc: func(_ *corev1.Pod) v1alpha2.ImageStatusSize { + return v1alpha2.ImageStatusSize{UnpackedBytes: "500Mi"} + }, + GetFormatFunc: func(_ *corev1.Pod) string { return "qcow2" }, + GetDownloadSpeedFunc: func(_ types.UID, _ *corev1.Pod) *v1alpha2.StatusSpeed { return nil }, + GetProgressFunc: func(_ types.UID, _ *corev1.Pod, prev string, _ ...service.GetProgressOption) string { + if prev == "" { + return "10%" + } + return prev + }, + CheckPodFunc: func(_ *corev1.Pod) error { return nil }, + } + }) + + newSyncer := func(c client.Client) *RegistryDataSource { + return NewRegistryDataSource(recorder, stat, importerSvc, disk, pvcSvc, dvcrSettings, c) + } + + Context("Validate", func() { + It("rejects nil container image", func() { + vd.Spec.DataSource.ContainerImage = nil + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + err := newSyncer(cl).Validate(ctx, vd) + Expect(err).To(HaveOccurred()) + }) + + It("requires the image pull secret to exist when referenced", func() { + vd.Spec.DataSource.ContainerImage.ImagePullSecret.Name = "missing-secret" + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + err := newSyncer(cl).Validate(ctx, vd) + Expect(err).To(MatchError(ErrSecretNotFound)) + }) + + It("accepts the spec when the image pull secret is present", func() { + vd.Spec.DataSource.ContainerImage.ImagePullSecret.Name = "secret" + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: vd.Namespace}} + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + err := newSyncer(cl).Validate(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("VirtualDisk has just been created (no importer pod yet)", func() { + It("starts the importer pod and sets DiskProvisioning", func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + var started bool + importerSvc.StartFunc = func(_ context.Context, _ *importer.Settings, _ client.Object, _ supplements.Generator, _ *datasource.CABundle, _ ...service.Option) error { + started = true + return nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(started).To(BeTrue()) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + }) + + It("propagates QuotaExceeded as DiskFailed/QuotaExceeded", func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + importerSvc.StartFunc = func(_ context.Context, _ *importer.Settings, _ client.Object, _ supplements.Generator, _ *datasource.CABundle, _ ...service.Option) error { + return errors.New("exceeded quota: storage requested but limit reached") + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskFailed)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.QuotaExceeded, true) + }) + }) + + Context("Importer pod is running", func() { + BeforeEach(func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "importer", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + }) + + It("reports Provisioning and protects the pod", func() { + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + Expect(importerSvc.ProtectCalls()).To(HaveLen(1)) + }) + }) + + Context("Importer pod completed, no PVC yet", func() { + BeforeEach(func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "importer", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + }) + + It("kicks off the PVC import using a registry source", func() { + var started bool + pvcSvc.CreateTargetFromDVCRFunc = func(_ context.Context, _ types.NamespacedName, _ string, _ *resource.Quantity, _ client.Object, source *service.PVCImportSourceRegistry, _ service.VolumeAndAccessModesGetter, _ *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + started = true + Expect(source).ToNot(BeNil()) + return corev1.PersistentVolumeClaim{}, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(started).To(BeTrue()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + }) + + It("fails the disk when the source is ISO", func() { + stat.GetFormatFunc = func(_ *corev1.Pod) string { return "iso" } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskFailed)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.ProvisioningFailed, true) + }) + }) + + Context("PVC is created but not yet Bound", func() { + BeforeEach(func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "importer", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + }) + + // With the prime/rebind import flow the importer fills a separate prime PVC + // and the target PVC only becomes Bound at the very end (via rebind), so a + // Pending target means the import is in progress, regardless of binding mode. + It("reports Provisioning for WFFC storage class", func() { + pvc.Status.Phase = corev1.ClaimPending + sc.VolumeBindingMode = ptr.To(storagev1.VolumeBindingWaitForFirstConsumer) + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc, sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + }) + + Context("PVC is Bound and the import is complete", func() { + BeforeEach(func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationDone: "true"} + }) + + It("marks DiskReady", func() { + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskReady)) + ExpectCondition(vd, metav1.ConditionTrue, vdcondition.Ready, false) + ExpectStats(vd) + }) + }) + + Context("PVC is Bound and the import is still in flight", func() { + BeforeEach(func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationStrategy: service.PopulationStrategyDVCR} + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "importer", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + importerSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + }) + + It("waits for populator when the target PVC already exists", func() { + var imported bool + pvcSvc.ImportFunc = func(_ context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { + imported = true + Expect(target.Name).To(Equal(pvc.Name)) + Expect(source).ToNot(BeNil()) + Expect(source.Registry).ToNot(BeNil()) + return nil + } + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + Expect(imported).To(BeTrue()) + Expect(source).ToNot(BeNil()) + Expect(source.Registry).ToNot(BeNil()) + return corev1.PodPending, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(imported).To(BeFalse()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + }) + + Context("CleanUp", func() { + It("delegates to both importer and disk services", func() { + var importerCleaned, diskCleaned bool + importerSvc.CleanUpFunc = func(_ context.Context, _ supplements.Generator) (bool, error) { + importerCleaned = true + return false, nil + } + disk.CleanUpFunc = func(_ context.Context, _ supplements.Generator) (bool, error) { + diskCleaned = true + return true, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + requeue, err := newSyncer(cl).CleanUp(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(requeue).To(BeTrue()) + Expect(importerCleaned).To(BeTrue()) + Expect(diskCleaned).To(BeTrue()) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/sources.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/sources.go index bbac959659..ded4062f64 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/sources.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/sources.go @@ -18,26 +18,10 @@ package source import ( "context" - "errors" "fmt" - "time" - corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/virtualization-controller/pkg/common/annotations" - "github.com/deckhouse/virtualization-controller/pkg/common/object" - "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/service" - "github.com/deckhouse/virtualization-controller/pkg/controller/service/volumemode" - "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" - "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source/step" - vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" ) @@ -84,299 +68,8 @@ func (s Sources) CleanUp(ctx context.Context, vd *v1alpha2.VirtualDisk) (bool, e return requeue, nil } -type SupplementsCleaner interface { - CleanUpSupplements(ctx context.Context, vd *v1alpha2.VirtualDisk) (reconcile.Result, error) -} - -func CleanUpSupplements(ctx context.Context, vd *v1alpha2.VirtualDisk, c SupplementsCleaner) (reconcile.Result, error) { - if object.ShouldCleanupSubResources(vd) { - return c.CleanUpSupplements(ctx, vd) - } - - return reconcile.Result{}, nil -} - +// IsDiskProvisioningFinished reports whether the disk has reached a terminal +// provisioning state: Ready, Lost, or Exporting. func IsDiskProvisioningFinished(c metav1.Condition) bool { return c.Reason == vdcondition.Ready.String() || c.Reason == vdcondition.Lost.String() || c.Reason == vdcondition.Exporting.String() } - -func setPhaseConditionForFinishedDisk( - vd *v1alpha2.VirtualDisk, - pvc *corev1.PersistentVolumeClaim, - cb *conditions.ConditionBuilder, - phase *v1alpha2.DiskPhase, - supgen supplements.Generator, -) { - var newPhase v1alpha2.DiskPhase - switch { - case pvc == nil: - newPhase = v1alpha2.DiskLost - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Lost, fmt.Sprintf("PVC %s not found.", supgen.PersistentVolumeClaim().String())) - case pvc.Status.Phase == corev1.ClaimLost: - if pvc.GetAnnotations()[annotations.AnnDataExportRequest] == "true" { - newPhase = v1alpha2.DiskExporting - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Exporting, "PV is being exported") - } else { - newPhase = v1alpha2.DiskLost - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Lost, fmt.Sprintf("PV %s not found.", pvc.Spec.VolumeName)) - } - default: - newPhase = v1alpha2.DiskReady - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionTrue, vdcondition.Ready, "") - } - if phase != nil && string(newPhase) != "" { - *phase = newPhase - } -} - -type CheckImportProcess interface { - CheckImportProcess(ctx context.Context, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error -} - -func setPhaseConditionFromStorageError(err error, vd *v1alpha2.VirtualDisk, cb *conditions.ConditionBuilder) (bool, error) { - switch { - case err == nil: - return false, nil - case errors.Is(err, volumemode.ErrStorageProfileNotFound): - vd.Status.Phase = v1alpha2.DiskPending - setReadyConditionWithWFFCAccounting( - vd, - cb, - metav1.ConditionFalse, - vdcondition.ProvisioningFailed, - "StorageProfile not found in the cluster: Please check a StorageClass name in the cluster or set a default StorageClass.", - ) - return true, nil - case errors.Is(err, service.ErrDefaultStorageClassNotFound): - vd.Status.Phase = v1alpha2.DiskPending - setReadyConditionWithWFFCAccounting( - vd, - cb, - metav1.ConditionFalse, - vdcondition.ProvisioningFailed, - "Default StorageClass not found in the cluster: please provide a StorageClass name or set a default StorageClass.", - ) - return true, nil - default: - return false, err - } -} - -func setPhaseConditionForPVCProvisioningDisk( - ctx context.Context, - dv *cdiv1.DataVolume, - vd *v1alpha2.VirtualDisk, - pvc *corev1.PersistentVolumeClaim, - sc *storagev1.StorageClass, - cb *conditions.ConditionBuilder, - checker CheckImportProcess, -) error { - err := checker.CheckImportProcess(ctx, dv, pvc) - switch { - case err == nil: - if dv == nil { - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "Waiting for the pvc importer to be created") - return nil - } - - dvRunningCond, _ := conditions.GetDataVolumeCondition(conditions.DVRunningConditionType, dv.Status.Conditions) - if isStorageClassWFFC(sc) && (dv.Status.Phase == cdiv1.PendingPopulation || dv.Status.Phase == cdiv1.WaitForFirstConsumer) && dvRunningCond.Reason == "" { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.WaitingForFirstConsumer). - Message("The provisioning has been suspended: a created and scheduled virtual machine is awaited") - return nil - } - - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "Import is in the process of provisioning to PVC.") - return nil - case errors.Is(err, service.ErrDataVolumeNotRunning): - vd.Status.Phase = v1alpha2.DiskFailed - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningFailed, service.CapitalizeFirstLetter(err.Error())) - return nil - default: - return err - } -} - -func addWaitingForFirstConsumerDuration(vd *v1alpha2.VirtualDisk, nextReason string) { - readyCondition, ok := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions) - if !ok || readyCondition.Reason != vdcondition.WaitingForFirstConsumer.String() || nextReason == vdcondition.WaitingForFirstConsumer.String() { - return - } - - if readyCondition.LastTransitionTime.IsZero() { - return - } - - wffcDuration := time.Since(readyCondition.LastTransitionTime.Time).Truncate(time.Second) - if wffcDuration <= 0 { - return - } - - if vd.Status.Stats.CreationDuration.WaitingForFirstConsumer == nil { - vd.Status.Stats.CreationDuration.WaitingForFirstConsumer = &metav1.Duration{ - Duration: wffcDuration, - } - return - } - - vd.Status.Stats.CreationDuration.WaitingForFirstConsumer.Duration += wffcDuration -} - -func setReadyConditionWithWFFCAccounting( - vd *v1alpha2.VirtualDisk, - cb *conditions.ConditionBuilder, - status metav1.ConditionStatus, - reason vdcondition.ReadyReason, - message string, -) { - addWaitingForFirstConsumerDuration(vd, reason.String()) - cb. - Status(status). - Reason(reason). - Message(message) -} - -func setPhaseConditionFromPodError( - ctx context.Context, - podErr error, - pod *corev1.Pod, - vd *v1alpha2.VirtualDisk, - cb *conditions.ConditionBuilder, - c client.Client, -) error { - switch { - case errors.Is(podErr, service.ErrNotInitialized): - vd.Status.Phase = v1alpha2.DiskFailed - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningNotStarted, service.CapitalizeFirstLetter(podErr.Error())+".") - return nil - case errors.Is(podErr, service.ErrNotScheduled): - vd.Status.Phase = v1alpha2.DiskPending - - nodePlacement, err := getNodePlacement(ctx, c, vd) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return fmt.Errorf("failed to get importer tolerations: %w", err) - } - - var isChanged bool - isChanged, err = provisioner.IsNodePlacementChanged(nodePlacement, pod) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return err - } - - if isChanged { - err = c.Delete(ctx, pod) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return err - } - - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningNotStarted, "Provisioner recreation due to a changes in the virtual machine tolerations.") - } else { - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningNotStarted, service.CapitalizeFirstLetter(podErr.Error())+".") - } - - return nil - case errors.Is(podErr, service.ErrProvisioningFailed): - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, podErr) - return nil - default: - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", podErr)) - return podErr - } -} - -type Cleaner interface { - CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) -} - -func setPhaseConditionFromProvisioningError( - ctx context.Context, - provisioningErr error, - cb *conditions.ConditionBuilder, - vd *v1alpha2.VirtualDisk, - dv *cdiv1.DataVolume, - cleaner Cleaner, - c client.Client, -) error { - switch { - case errors.Is(provisioningErr, service.ErrDataVolumeProvisionerUnschedulable): - nodePlacement, err := getNodePlacement(ctx, c, vd) - if err != nil { - err = errors.Join(provisioningErr, err) - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, err) - return err - } - - isChanged, err := provisioner.IsNodePlacementChanged(nodePlacement, dv) - if err != nil { - err = errors.Join(provisioningErr, err) - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, err) - return err - } - - vd.Status.Phase = v1alpha2.DiskProvisioning - - if isChanged { - supgen := vdsupplements.NewGenerator(vd) - - _, err = cleaner.CleanUp(ctx, supgen) - if err != nil { - err = errors.Join(provisioningErr, err) - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, err) - return err - } - - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "PVC provisioner recreation due to a changes in the virtual machine tolerations.") - } else { - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "Trying to schedule the PVC provisioner.") - } - - return nil - default: - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, provisioningErr) - return provisioningErr - } -} - -// Deprecated. -func getNodePlacement(ctx context.Context, c client.Client, vd *v1alpha2.VirtualDisk) (*provisioner.NodePlacement, error) { - return step.GetNodePlacement(ctx, c, vd) -} - -const retryPeriod = 1 - -func setQuotaExceededPhaseCondition(vd *v1alpha2.VirtualDisk, cb *conditions.ConditionBuilder, phase *v1alpha2.DiskPhase, err error, creationTimestamp metav1.Time) reconcile.Result { - *phase = v1alpha2.DiskFailed - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningFailed, "") - - if creationTimestamp.Add(30 * time.Minute).After(time.Now()) { - cb.Message(fmt.Sprintf("Quota exceeded: %s; Please configure quotas or try recreating the resource later.", err)) - return reconcile.Result{} - } - - cb.Message(fmt.Sprintf("Quota exceeded: %s; Retry in %d minute.", err, retryPeriod)) - return reconcile.Result{RequeueAfter: retryPeriod * time.Minute} -} - -func setPhaseConditionToFailed(vd *v1alpha2.VirtualDisk, cb *conditions.ConditionBuilder, phase *v1alpha2.DiskPhase, err error) { - *phase = v1alpha2.DiskFailed - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningFailed, service.CapitalizeFirstLetter(err.Error())+".") -} - -func isStorageClassWFFC(sc *storagev1.StorageClass) bool { - return sc != nil && sc.VolumeBindingMode != nil && *sc.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer -} - -const ( - DVRunningConditionType cdiv1.DataVolumeConditionType = "Running" - DVQoutaNotExceededConditionType cdiv1.DataVolumeConditionType = "QuotaNotExceeded" - - DVImagePullFailedReason = "ImagePullFailed" -) diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/clean_up_importer_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/clean_up_importer_step.go new file mode 100644 index 0000000000..1d7bee52f3 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/clean_up_importer_step.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +type CleanUpImporterStepImporterService interface { + CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) +} + +// CleanUpImporterStep deletes the importer Pod once the disk has reached a +// final state (Ready, Lost or Exporting). It is a no-op while the disk is +// still being provisioned and when there is nothing left to clean up. +type CleanUpImporterStep struct { + pod *corev1.Pod + importer CleanUpImporterStepImporterService +} + +func NewCleanUpImporterStep( + pod *corev1.Pod, + importer CleanUpImporterStepImporterService, +) *CleanUpImporterStep { + return &CleanUpImporterStep{ + pod: pod, + importer: importer, + } +} + +func (s CleanUpImporterStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if s.pod == nil { + return nil, nil + } + + condition, _ := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions) + if !isDiskProvisioningFinished(condition.Reason) { + return nil, nil + } + + supgen := vdsupplements.NewGenerator(vd) + if _, err := s.importer.CleanUp(ctx, supgen); err != nil { + return nil, fmt.Errorf("clean up importer supplements: %w", err) + } + + return nil, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/clean_up_uploader_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/clean_up_uploader_step.go new file mode 100644 index 0000000000..dc2a7bd000 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/clean_up_uploader_step.go @@ -0,0 +1,86 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +type CleanUpUploaderStepUploaderService interface { + CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) +} + +// CleanUpUploaderStep deletes uploader Pod/Service/Ingress once the disk has +// reached a final state (Ready, Lost or Exporting). It is a no-op while the +// disk is still being provisioned and when there is nothing left to clean up. +type CleanUpUploaderStep struct { + pod *corev1.Pod + svc *corev1.Service + ing *netv1.Ingress + uploader CleanUpUploaderStepUploaderService +} + +func NewCleanUpUploaderStep( + pod *corev1.Pod, + svc *corev1.Service, + ing *netv1.Ingress, + uploader CleanUpUploaderStepUploaderService, +) *CleanUpUploaderStep { + return &CleanUpUploaderStep{ + pod: pod, + svc: svc, + ing: ing, + uploader: uploader, + } +} + +func (s CleanUpUploaderStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if s.pod == nil && s.svc == nil && s.ing == nil { + return nil, nil + } + + condition, _ := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions) + if !isDiskProvisioningFinished(condition.Reason) { + return nil, nil + } + + supgen := vdsupplements.NewGenerator(vd) + if _, err := s.uploader.CleanUp(ctx, supgen); err != nil { + return nil, fmt.Errorf("clean up uploader supplements: %w", err) + } + + return nil, nil +} + +// isDiskProvisioningFinished reports whether the disk has reached a terminal +// provisioning state: Ready, Lost, or Exporting. +func isDiskProvisioningFinished(reason string) bool { + return reason == vdcondition.Ready.String() || + reason == vdcondition.Lost.String() || + reason == vdcondition.Exporting.String() +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_blank_pvc_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_blank_pvc_step.go index 0d56c6b194..2279ef3484 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_blank_pvc_step.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_blank_pvc_step.go @@ -24,15 +24,14 @@ import ( corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" - k8serrors "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/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common/object" - pvcspec "github.com/deckhouse/virtualization-controller/pkg/common/pvc" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/service" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" @@ -43,13 +42,17 @@ import ( const createStep = "create" -type VolumeAndAccessModesGetter interface { - GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) +type VolumeAndAccessModesGetter = service.VolumeAndAccessModesGetter + +type BlankPVCService interface { + Finalizers() []string + CreateBlankTarget(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, modeGetter VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) } type CreateBlankPVCStep struct { pvc *corev1.PersistentVolumeClaim modeGetter VolumeAndAccessModesGetter + pvcSvc BlankPVCService client client.Client cb *conditions.ConditionBuilder } @@ -57,12 +60,14 @@ type CreateBlankPVCStep struct { func NewCreateBlankPVCStep( pvc *corev1.PersistentVolumeClaim, modeGetter VolumeAndAccessModesGetter, + pvcSvc BlankPVCService, client client.Client, cb *conditions.ConditionBuilder, ) *CreateBlankPVCStep { return &CreateBlankPVCStep{ pvc: pvc, modeGetter: modeGetter, + pvcSvc: pvcSvc, client: client, cb: cb, } @@ -90,38 +95,22 @@ func (s CreateBlankPVCStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) return nil, fmt.Errorf("storage class %q not found", vd.Status.StorageClassName) } - volumeMode, accessMode, err := s.modeGetter.GetVolumeAndAccessModes(ctx, vd, sc) - if err != nil { - return nil, fmt.Errorf("get volume and access modes: %w", err) - } - key := vdsupplements.NewGenerator(vd).PersistentVolumeClaim() - pvc := corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: key.Namespace, - Finalizers: []string{ - v1alpha2.FinalizerVDProtection, - }, - OwnerReferences: []metav1.OwnerReference{ - service.MakeOwnerReference(vd), - }, - }, - Spec: ptr.Deref( - pvcspec.CreateSpec(&sc.Name, *vd.Spec.PersistentVolumeClaim.Size, accessMode, volumeMode), - corev1.PersistentVolumeClaimSpec{}, - ), - } - - log := logger.FromContext(ctx).With(logger.SlogStep(createStep)).With("pvc.name", pvc.Name) + log := logger.FromContext(ctx).With(logger.SlogStep(createStep)).With("pvc.name", key.Name) log.Debug("Create new PVC") - err = s.client.Create(ctx, &pvc) - if err != nil && !k8serrors.IsAlreadyExists(err) { + nodePlacement, err := GetNodePlacement(ctx, s.client, vd) + if err != nil { + return nil, fmt.Errorf("failed to get importer tolerations: %w", err) + } + + pvc, err := s.pvcSvc.CreateBlankTarget(ctx, key, sc.Name, vd.Spec.PersistentVolumeClaim.Size, vd, s.modeGetter, nodePlacement) + if err != nil { if strings.Contains(err.Error(), "exceeded quota") { log.Debug("Quota exceeded during PVC creation") vd.Status.Phase = v1alpha2.DiskPending + vd.Status.Progress = "" s.cb. Status(metav1.ConditionFalse). Reason(vdcondition.QuotaExceeded). diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_step.go deleted file mode 100644 index f05746f5dc..0000000000 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_step.go +++ /dev/null @@ -1,149 +0,0 @@ -/* -Copyright 2025 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package step - -import ( - "context" - "errors" - "fmt" - - storagev1 "k8s.io/api/storage/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/deckhouse/virtualization-controller/pkg/common/object" - "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/service" - "github.com/deckhouse/virtualization-controller/pkg/controller/service/volumemode" - "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" - vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" -) - -type CreateDataVolumeStepDiskService interface { - Start(ctx context.Context, pvcSize resource.Quantity, sc *storagev1.StorageClass, source *cdiv1.DataVolumeSource, obj client.Object, sup supplements.DataVolumeSupplement, opts ...service.Option) error -} - -type CreateDataVolumeStep struct { - dv *cdiv1.DataVolume - disk CreateDataVolumeStepDiskService - client client.Client - source *cdiv1.DataVolumeSource - size resource.Quantity - cb *conditions.ConditionBuilder -} - -func NewCreateDataVolumeStep( - dv *cdiv1.DataVolume, - disk CreateDataVolumeStepDiskService, - client client.Client, - source *cdiv1.DataVolumeSource, - size resource.Quantity, - cb *conditions.ConditionBuilder, -) *CreateDataVolumeStep { - return &CreateDataVolumeStep{ - dv: dv, - disk: disk, - client: client, - source: source, - size: size, - cb: cb, - } -} - -func (s CreateDataVolumeStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { - if s.dv != nil { - return nil, nil - } - - supgen := vdsupplements.NewGenerator(vd) - - vd.Status.Progress = "0%" - - sc, err := object.FetchObject(ctx, types.NamespacedName{Name: vd.Status.StorageClassName}, s.client, &storagev1.StorageClass{}) - if err != nil { - return nil, fmt.Errorf("get sc: %w", err) - } - - isWFFC := sc != nil && sc.VolumeBindingMode != nil && *sc.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer - if isWFFC && len(vd.Status.AttachedToVirtualMachines) != 1 { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - return &reconcile.Result{}, nil - } - - var nodePlacement *provisioner.NodePlacement - nodePlacement, err = GetNodePlacement(ctx, s.client, vd) - if err != nil { - return nil, fmt.Errorf("failed to get importer tolerations: %w", err) - } - - err = s.disk.Start(ctx, s.size, sc, s.source, vd, supgen, service.WithNodePlacement(nodePlacement)) - switch { - case err == nil: - // OK. - case errors.Is(err, volumemode.ErrStorageProfileNotFound): - vd.Status.Phase = v1alpha2.DiskFailed - s.cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ProvisioningFailed). - Message("StorageProfile not found in the cluster: Please check a StorageProfile name in the cluster.") - return &reconcile.Result{}, nil - default: - return nil, fmt.Errorf("start immediate: %w", err) - } - - return nil, nil -} - -func GetNodePlacement(ctx context.Context, c client.Client, vd *v1alpha2.VirtualDisk) (*provisioner.NodePlacement, error) { - if len(vd.Status.AttachedToVirtualMachines) != 1 { - return nil, nil - } - - vmKey := types.NamespacedName{Name: vd.Status.AttachedToVirtualMachines[0].Name, Namespace: vd.Namespace} - vm, err := object.FetchObject(ctx, vmKey, c, &v1alpha2.VirtualMachine{}) - if err != nil { - return nil, fmt.Errorf("unable to get the virtual machine %s: %w", vmKey, err) - } - - if vm == nil { - return nil, nil - } - - var nodePlacement provisioner.NodePlacement - nodePlacement.Tolerations = append(nodePlacement.Tolerations, vm.Spec.Tolerations...) - - vmClassKey := types.NamespacedName{Name: vm.Spec.VirtualMachineClassName} - vmClass, err := object.FetchObject(ctx, vmClassKey, c, &v1alpha2.VirtualMachineClass{}) - if err != nil { - return nil, fmt.Errorf("unable to get the virtual machine class %s: %w", vmClassKey, err) - } - - if vmClass == nil { - return &nodePlacement, nil - } - - nodePlacement.Tolerations = append(nodePlacement.Tolerations, vmClass.Spec.Tolerations...) - - return &nodePlacement, nil -} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_importer_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_importer_step.go new file mode 100644 index 0000000000..6562d7bc54 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_importer_step.go @@ -0,0 +1,131 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/datasource" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/importer" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +type CreateImporterStepImporterService interface { + Start(ctx context.Context, settings *importer.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error +} + +// ImporterEnvSettingsBuilder builds importer environment settings for a given +// VirtualDisk. Data sources provide their source-specific build logic via this +// callback so the step itself stays generic. +type ImporterEnvSettingsBuilder func(vd *v1alpha2.VirtualDisk, supgen supplements.Generator) *importer.Settings + +// CreateImporterStep creates an importer Pod that downloads source data into +// DVCR. It is a no-op once the underlying PVC has been created so the importer +// is not recreated after cleanup. +type CreateImporterStep struct { + pvc *corev1.PersistentVolumeClaim + pod *corev1.Pod + settingsBuilder ImporterEnvSettingsBuilder + importer CreateImporterStepImporterService + recorder eventrecord.EventRecorderLogger + cb *conditions.ConditionBuilder + eventText string +} + +func NewCreateImporterStep( + pvc *corev1.PersistentVolumeClaim, + pod *corev1.Pod, + settingsBuilder ImporterEnvSettingsBuilder, + importer CreateImporterStepImporterService, + recorder eventrecord.EventRecorderLogger, + cb *conditions.ConditionBuilder, + eventText string, +) *CreateImporterStep { + return &CreateImporterStep{ + pvc: pvc, + pod: pod, + settingsBuilder: settingsBuilder, + importer: importer, + recorder: recorder, + cb: cb, + eventText: eventText, + } +} + +func (s CreateImporterStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + // Importer is needed only until the underlying PVC has been created. + // Once the PVC exists the data has been pushed to DVCR and the importer + // supplements are not recreated even if they are missing. + if s.pvc != nil { + return nil, nil + } + + if s.pod != nil { + return nil, nil + } + + s.recorder.Event( + vd, + corev1.EventTypeNormal, + v1alpha2.ReasonDataSourceSyncStarted, + s.eventText, + ) + + vd.Status.Progress = "0%" + + supgen := vdsupplements.NewGenerator(vd) + settings := s.settingsBuilder(vd, supgen) + caBundle := datasource.NewCABundleForVMD(vd.GetNamespace(), vd.Spec.DataSource) + + err := s.importer.Start(ctx, settings, vd, supgen, caBundle, service.WithSystemNodeToleration()) + switch { + case err == nil: + // OK. + case common.ErrQuotaExceeded(err): + s.recorder.Event(vd, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceQuotaExceeded, "DataSource quota exceed") + vd.Status.Phase = v1alpha2.DiskFailed + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.QuotaExceeded). + Message(fmt.Sprintf("Quota exceeded during the importer provisioning: %s", err)) + return &reconcile.Result{}, nil + default: + return nil, fmt.Errorf("start importer: %w", err) + } + + vd.Status.Phase = v1alpha2.DiskProvisioning + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message("DVCR Provisioner not found: create the new one.") + + return &reconcile.Result{RequeueAfter: time.Second}, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_from_vdsnapshot_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_from_vdsnapshot_step.go index 891d5b5108..6808eb65e4 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_from_vdsnapshot_step.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_from_vdsnapshot_step.go @@ -20,7 +20,6 @@ import ( "context" "errors" "fmt" - "strings" vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" corev1 "k8s.io/api/core/v1" @@ -35,6 +34,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/common/annotations" "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/service" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" @@ -46,19 +46,29 @@ import ( type CreatePVCFromVDSnapshotStep struct { pvc *corev1.PersistentVolumeClaim + disk service.VolumeAndAccessModesGetter + pvcSvc CreatePVCFromVDSnapshotStepPVCService recorder eventrecord.EventRecorderLogger client client.Client cb *conditions.ConditionBuilder } +type CreatePVCFromVDSnapshotStepPVCService interface { + CreateTargetFromVS(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) +} + func NewCreatePVCFromVDSnapshotStep( pvc *corev1.PersistentVolumeClaim, + disk service.VolumeAndAccessModesGetter, + pvcSvc CreatePVCFromVDSnapshotStepPVCService, recorder eventrecord.EventRecorderLogger, client client.Client, cb *conditions.ConditionBuilder, ) *CreatePVCFromVDSnapshotStep { return &CreatePVCFromVDSnapshotStep{ pvc: pvc, + disk: disk, + pvcSvc: pvcSvc, recorder: recorder, client: client, cb: cb, @@ -84,6 +94,7 @@ func (s CreatePVCFromVDSnapshotStep) Take(ctx context.Context, vd *v1alpha2.Virt if vdSnapshot == nil { vd.Status.Phase = v1alpha2.DiskPending + vd.Status.Progress = "" s.cb. Status(metav1.ConditionFalse). Reason(vdcondition.ProvisioningNotStarted). @@ -98,6 +109,7 @@ func (s CreatePVCFromVDSnapshotStep) Take(ctx context.Context, vd *v1alpha2.Virt if vdSnapshot.Status.Phase != v1alpha2.VirtualDiskSnapshotPhaseReady || vs == nil || vs.Status == nil || vs.Status.ReadyToUse == nil || !*vs.Status.ReadyToUse { vd.Status.Phase = v1alpha2.DiskPending + vd.Status.Progress = "" s.cb. Status(metav1.ConditionFalse). Reason(vdcondition.ProvisioningNotStarted). @@ -120,7 +132,11 @@ func (s CreatePVCFromVDSnapshotStep) Take(ctx context.Context, vd *v1alpha2.Virt return &reconcile.Result{}, nil } - pvc, err := s.buildPVC(vd, vs) + storageClassName := s.storageClassName(vd, vs) + if storageClassName != "" { + vd.Status.StorageClassName = storageClassName + } + size, err := s.getPVCSize(vd, vs) if err != nil { if errors.Is(err, service.ErrInsufficientPVCSize) { vd.Status.Phase = v1alpha2.DiskFailed @@ -140,7 +156,8 @@ func (s CreatePVCFromVDSnapshotStep) Take(ctx context.Context, vd *v1alpha2.Virt return nil, err } - err = s.client.Create(ctx, pvc) + key := vdsupplements.NewGenerator(vd).PersistentVolumeClaim() + pvc, err := s.pvcSvc.CreateTargetFromVS(ctx, key, storageClassName, size, vd, vs, s.disk, nil) if err != nil && !k8serrors.IsAlreadyExists(err) { return nil, fmt.Errorf("create pvc: %w", err) } @@ -158,78 +175,15 @@ func (s CreatePVCFromVDSnapshotStep) Take(ctx context.Context, vd *v1alpha2.Virt return nil, nil } -func (s CreatePVCFromVDSnapshotStep) buildPVC(vd *v1alpha2.VirtualDisk, vs *vsv1.VolumeSnapshot) (*corev1.PersistentVolumeClaim, error) { - var storageClassName string +func (s CreatePVCFromVDSnapshotStep) storageClassName(vd *v1alpha2.VirtualDisk, vs *vsv1.VolumeSnapshot) string { if vd.Spec.PersistentVolumeClaim.StorageClass != nil && *vd.Spec.PersistentVolumeClaim.StorageClass != "" { - storageClassName = *vd.Spec.PersistentVolumeClaim.StorageClass - } else { - storageClassName = vs.Annotations[annotations.AnnStorageClassName] - if storageClassName == "" { - storageClassName = vs.Annotations[annotations.AnnStorageClassNameDeprecated] - } - } - - volumeMode := vs.Annotations[annotations.AnnVolumeMode] - if volumeMode == "" { - volumeMode = vs.Annotations[annotations.AnnVolumeModeDeprecated] - } - accessModesRaw := vs.Annotations[annotations.AnnAccessModes] - if accessModesRaw == "" { - accessModesRaw = vs.Annotations[annotations.AnnAccessModesDeprecated] - } - - accessModesStr := strings.Split(accessModesRaw, ",") - accessModes := make([]corev1.PersistentVolumeAccessMode, 0, len(accessModesStr)) - for _, accessModeStr := range accessModesStr { - accessModes = append(accessModes, corev1.PersistentVolumeAccessMode(accessModeStr)) - } - - spec := corev1.PersistentVolumeClaimSpec{ - AccessModes: accessModes, - DataSource: &corev1.TypedLocalObjectReference{ - APIGroup: ptr.To(vs.GroupVersionKind().Group), - Kind: vs.Kind, - Name: vs.Name, - }, + return *vd.Spec.PersistentVolumeClaim.StorageClass } - - if storageClassName != "" { - spec.StorageClassName = &storageClassName - vd.Status.StorageClassName = storageClassName - } - - if volumeMode != "" { - spec.VolumeMode = ptr.To(corev1.PersistentVolumeMode(volumeMode)) - } - - size, err := s.getPVCSize(vd, vs) - if err != nil { - return nil, err + storageClassName := vs.Annotations[annotations.AnnStorageClassName] + if storageClassName == "" { + storageClassName = vs.Annotations[annotations.AnnStorageClassNameDeprecated] } - - if size != nil { - spec.Resources = corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: *size, - }, - } - } - - pvcKey := vdsupplements.NewGenerator(vd).PersistentVolumeClaim() - - return &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: pvcKey.Name, - Namespace: pvcKey.Namespace, - Finalizers: []string{ - v1alpha2.FinalizerVDProtection, - }, - OwnerReferences: []metav1.OwnerReference{ - service.MakeOwnerReference(vd), - }, - }, - Spec: spec, - }, nil + return storageClassName } func (s CreatePVCFromVDSnapshotStep) getPVCSize(vd *v1alpha2.VirtualDisk, vs *vsv1.VolumeSnapshot) (*resource.Quantity, error) { diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_from_cvi_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_cvi_step.go similarity index 74% rename from images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_from_cvi_step.go rename to images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_cvi_step.go index da290ff1f7..c8bf813229 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_from_cvi_step.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_cvi_step.go @@ -26,7 +26,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -40,32 +39,32 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" ) -type CreateDataVolumeFromClusterVirtualImageStep struct { +type StartImportFromClusterVirtualImageStep struct { pvc *corev1.PersistentVolumeClaim - dv *cdiv1.DataVolume - disk CreateDataVolumeStepDiskService + disk PVCImportStepDiskService + pvcSvc PVCService client client.Client cb *conditions.ConditionBuilder } -func NewCreateDataVolumeFromClusterVirtualImageStep( +func NewCreatePVCFromClusterVirtualImageStep( pvc *corev1.PersistentVolumeClaim, - dv *cdiv1.DataVolume, - disk CreateDataVolumeStepDiskService, + disk PVCImportStepDiskService, + pvcSvc PVCService, client client.Client, cb *conditions.ConditionBuilder, -) *CreateDataVolumeFromClusterVirtualImageStep { - return &CreateDataVolumeFromClusterVirtualImageStep{ +) *StartImportFromClusterVirtualImageStep { + return &StartImportFromClusterVirtualImageStep{ pvc: pvc, - dv: dv, disk: disk, + pvcSvc: pvcSvc, client: client, cb: cb, } } -func (s CreateDataVolumeFromClusterVirtualImageStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { - if s.pvc != nil || s.dv != nil { +func (s StartImportFromClusterVirtualImageStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if s.pvc != nil { return nil, nil } @@ -106,10 +105,10 @@ func (s CreateDataVolumeFromClusterVirtualImageStep) Take(ctx context.Context, v return nil, err } - return NewCreateDataVolumeStep(s.dv, s.disk, s.client, source, size, s.cb).Take(ctx, vd) + return NewCreatePVCStep(s.disk, s.pvcSvc, s.client, source, size, s.cb).Take(ctx, vd) } -func (s CreateDataVolumeFromClusterVirtualImageStep) getPVCSize(vd *v1alpha2.VirtualDisk, cviRef *v1alpha2.ClusterVirtualImage) (resource.Quantity, error) { +func (s StartImportFromClusterVirtualImageStep) getPVCSize(vd *v1alpha2.VirtualDisk, cviRef *v1alpha2.ClusterVirtualImage) (resource.Quantity, error) { unpackedSize, err := resource.ParseQuantity(cviRef.Status.Size.UnpackedBytes) if err != nil { return resource.Quantity{}, fmt.Errorf("failed to parse unpacked bytes %s: %w", cviRef.Status.Size.UnpackedBytes, err) @@ -122,18 +121,16 @@ func (s CreateDataVolumeFromClusterVirtualImageStep) getPVCSize(vd *v1alpha2.Vir return service.GetValidatedPVCSize(vd.Spec.PersistentVolumeClaim.Size, unpackedSize) } -func (s CreateDataVolumeFromClusterVirtualImageStep) getSource(vd *v1alpha2.VirtualDisk, cviRef *v1alpha2.ClusterVirtualImage) *cdiv1.DataVolumeSource { +func (s StartImportFromClusterVirtualImageStep) getSource(vd *v1alpha2.VirtualDisk, cviRef *v1alpha2.ClusterVirtualImage) *service.PVCImportSource { + return BuildClusterVirtualImagePVCImportSource(vd, cviRef) +} + +func BuildClusterVirtualImagePVCImportSource(vd *v1alpha2.VirtualDisk, cviRef *v1alpha2.ClusterVirtualImage) *service.PVCImportSource { supgen := vdsupplements.NewGenerator(vd) url := common.DockerRegistrySchemePrefix + cviRef.Status.Target.RegistryURL secretName := supgen.DVCRAuthSecretForDV().Name certConfigMapName := supgen.DVCRCABundleConfigMapForDV().Name - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - } + return service.NewPVCRegistryImportSource(url, secretName, certConfigMapName) } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_dvcr_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_dvcr_step.go new file mode 100644 index 0000000000..72115660c9 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_dvcr_step.go @@ -0,0 +1,141 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "errors" + "fmt" + + 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" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" + podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +type StartImportFromDVCRStepStatService interface { + GetSize(pod *corev1.Pod) v1alpha2.ImageStatusSize + GetFormat(pod *corev1.Pod) string + GetDVCRImageName(pod *corev1.Pod) string + GetDownloadSpeed(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed +} + +// StartImportFromDVCRStep starts the PVC import from DVCR once the helper Pod +// (uploader or importer) has finished populating DVCR. It is a no-op while the +// PVC already exists or the Pod has not yet succeeded. +type StartImportFromDVCRStep struct { + pvc *corev1.PersistentVolumeClaim + pod *corev1.Pod + stat StartImportFromDVCRStepStatService + disk PVCImportStepDiskService + pvcSvc PVCService + client client.Client + cb *conditions.ConditionBuilder +} + +func NewCreatePVCFromDVCRStep( + pvc *corev1.PersistentVolumeClaim, + pod *corev1.Pod, + stat StartImportFromDVCRStepStatService, + disk PVCImportStepDiskService, + pvcSvc PVCService, + client client.Client, + cb *conditions.ConditionBuilder, +) *StartImportFromDVCRStep { + return &StartImportFromDVCRStep{ + pvc: pvc, + pod: pod, + stat: stat, + disk: disk, + pvcSvc: pvcSvc, + client: client, + cb: cb, + } +} + +func (s StartImportFromDVCRStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if s.pvc != nil { + return nil, nil + } + + if !podutil.IsPodComplete(s.pod) { + return nil, nil + } + + vd.Status.Progress = "50.0%" + vd.Status.DownloadSpeed = s.stat.GetDownloadSpeed(vd.GetUID(), s.pod) + + if imageformat.IsISO(s.stat.GetFormat(s.pod)) { + vd.Status.Phase = v1alpha2.DiskFailed + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningFailed). + Message(service.CapitalizeFirstLetter(ErrISOSourceNotSupported.Error()) + ".") + return &reconcile.Result{}, nil + } + + size, err := s.getPVCSize(vd) + if err != nil { + if errors.Is(err, service.ErrInsufficientPVCSize) { + vd.Status.Phase = v1alpha2.DiskFailed + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningFailed). + Message(service.CapitalizeFirstLetter(err.Error()) + ".") + return &reconcile.Result{}, nil + } + + return nil, err + } + + source := BuildDVCRPVCImportSource(vd, s.stat.GetDVCRImageName(s.pod)) + + return NewCreatePVCStep(s.disk, s.pvcSvc, s.client, source, size, s.cb).Take(ctx, vd) +} + +func (s StartImportFromDVCRStep) getPVCSize(vd *v1alpha2.VirtualDisk) (resource.Quantity, error) { + unpackedSize, err := resource.ParseQuantity(s.stat.GetSize(s.pod).UnpackedBytes) + if err != nil { + return resource.Quantity{}, fmt.Errorf("failed to parse unpacked bytes %s: %w", s.stat.GetSize(s.pod).UnpackedBytes, err) + } + + return service.GetValidatedPVCSize(vd.Spec.PersistentVolumeClaim.Size, unpackedSize) +} + +// BuildDVCRPVCImportSource constructs a PVCImportSource for a DVCR registry +// image. The image name is typically resolved from a helper Pod that uploaded +// or downloaded the data into DVCR. +func BuildDVCRPVCImportSource(vd *v1alpha2.VirtualDisk, dvcrImageName string) *service.PVCImportSource { + supgen := vdsupplements.NewGenerator(vd) + + url := common.DockerRegistrySchemePrefix + dvcrImageName + secretName := supgen.DVCRAuthSecretForDV().Name + certConfigMapName := supgen.DVCRCABundleConfigMapForDV().Name + + return service.NewPVCRegistryImportSource(url, secretName, certConfigMapName) +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_from_vi_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_vi_step.go similarity index 74% rename from images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_from_vi_step.go rename to images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_vi_step.go index 959f65ecfd..0fced9a4b4 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_dv_from_vi_step.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_from_vi_step.go @@ -26,7 +26,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -40,32 +39,32 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" ) -type CreateDataVolumeFromVirtualImageStep struct { +type StartImportFromVirtualImageStep struct { pvc *corev1.PersistentVolumeClaim - dv *cdiv1.DataVolume - disk CreateDataVolumeStepDiskService + disk PVCImportStepDiskService + pvcSvc PVCService client client.Client cb *conditions.ConditionBuilder } -func NewCreateDataVolumeFromVirtualImageStep( +func NewCreatePVCFromVirtualImageStep( pvc *corev1.PersistentVolumeClaim, - dv *cdiv1.DataVolume, - disk CreateDataVolumeStepDiskService, + disk PVCImportStepDiskService, + pvcSvc PVCService, client client.Client, cb *conditions.ConditionBuilder, -) *CreateDataVolumeFromVirtualImageStep { - return &CreateDataVolumeFromVirtualImageStep{ +) *StartImportFromVirtualImageStep { + return &StartImportFromVirtualImageStep{ pvc: pvc, - dv: dv, disk: disk, + pvcSvc: pvcSvc, client: client, cb: cb, } } -func (s CreateDataVolumeFromVirtualImageStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { - if s.pvc != nil || s.dv != nil { +func (s StartImportFromVirtualImageStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if s.pvc != nil { return nil, nil } @@ -109,10 +108,10 @@ func (s CreateDataVolumeFromVirtualImageStep) Take(ctx context.Context, vd *v1al return nil, err } - return NewCreateDataVolumeStep(s.dv, s.disk, s.client, source, size, s.cb).Take(ctx, vd) + return NewCreatePVCStep(s.disk, s.pvcSvc, s.client, source, size, s.cb).Take(ctx, vd) } -func (s CreateDataVolumeFromVirtualImageStep) getPVCSize(vd *v1alpha2.VirtualDisk, viRef *v1alpha2.VirtualImage) (resource.Quantity, error) { +func (s StartImportFromVirtualImageStep) getPVCSize(vd *v1alpha2.VirtualDisk, viRef *v1alpha2.VirtualImage) (resource.Quantity, error) { unpackedSize, err := resource.ParseQuantity(viRef.Status.Size.UnpackedBytes) if err != nil { return resource.Quantity{}, fmt.Errorf("failed to parse unpacked bytes %s: %w", viRef.Status.Size.UnpackedBytes, err) @@ -125,15 +124,14 @@ func (s CreateDataVolumeFromVirtualImageStep) getPVCSize(vd *v1alpha2.VirtualDis return service.GetValidatedPVCSize(vd.Spec.PersistentVolumeClaim.Size, unpackedSize) } -func (s CreateDataVolumeFromVirtualImageStep) getSource(vd *v1alpha2.VirtualDisk, viRef *v1alpha2.VirtualImage) (*cdiv1.DataVolumeSource, error) { +func (s StartImportFromVirtualImageStep) getSource(vd *v1alpha2.VirtualDisk, viRef *v1alpha2.VirtualImage) (*service.PVCImportSource, error) { + return BuildVirtualImagePVCImportSource(vd, viRef) +} + +func BuildVirtualImagePVCImportSource(vd *v1alpha2.VirtualDisk, viRef *v1alpha2.VirtualImage) (*service.PVCImportSource, error) { switch viRef.Spec.Storage { case v1alpha2.StoragePersistentVolumeClaim, v1alpha2.StorageKubernetes: - return &cdiv1.DataVolumeSource{ - PVC: &cdiv1.DataVolumeSourcePVC{ - Name: viRef.Status.Target.PersistentVolumeClaim, - Namespace: viRef.Namespace, - }, - }, nil + return service.NewPVCPVCImportSource(viRef.Status.Target.PersistentVolumeClaim, viRef.Namespace), nil case v1alpha2.StorageContainerRegistry, "": supgen := vdsupplements.NewGenerator(vd) @@ -141,13 +139,7 @@ func (s CreateDataVolumeFromVirtualImageStep) getSource(vd *v1alpha2.VirtualDisk secretName := supgen.DVCRAuthSecretForDV().Name certConfigMapName := supgen.DVCRCABundleConfigMapForDV().Name - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - }, nil + return service.NewPVCRegistryImportSource(url, secretName, certConfigMapName), nil default: return nil, fmt.Errorf("unexpected virtual image storage %s, please report a bug", viRef.Spec.Storage) } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_step.go new file mode 100644 index 0000000000..96d8cb4e2f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_pvc_import_step.go @@ -0,0 +1,223 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "errors" + "fmt" + + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/volumemode" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +// PVCImportStepDiskService is the slice of DiskService that the step needs to +// resolve volume/access modes for the target PVC. +type PVCImportStepDiskService interface { + VolumeAndAccessModesGetter +} + +// PVCService is the contract every PVC-import step relies on to create and +// populate a target PersistentVolumeClaim. +type PVCService interface { + CreateTargetFromDVCR(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *service.PVCImportSourceRegistry, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) + CreateTargetFromPVC(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *corev1.PersistentVolumeClaim, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) + CreateTargetFromVS(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) + Import(ctx context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) error + WaitForImport(ctx context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, owner client.Object, sup supplements.Generator, nodePlacement *provisioner.NodePlacement) (corev1.PodPhase, error) +} + +// PVCImportStep is the step that initiates target PVC creation for a +// VirtualDisk import. It explicitly chooses the target creation method from the +// import source and creates the target PVC with the annotations/dataSource +// needed by the wait step. +// +// The step is idempotent: subsequent invocations are no-ops once the target +// PVC already exists. +type PVCImportStep struct { + disk PVCImportStepDiskService + pvc PVCService + client client.Client + source *service.PVCImportSource + size resource.Quantity + cb *conditions.ConditionBuilder +} + +func NewCreatePVCStep( + disk PVCImportStepDiskService, + pvcSvc PVCService, + c client.Client, + source *service.PVCImportSource, + size resource.Quantity, + cb *conditions.ConditionBuilder, +) *PVCImportStep { + return &PVCImportStep{ + disk: disk, + pvc: pvcSvc, + client: c, + source: source, + size: size, + cb: cb, + } +} + +func (s PVCImportStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if vd.Status.Progress == "" { + vd.Status.Progress = "0%" + } + + sc, err := object.FetchObject(ctx, types.NamespacedName{Name: vd.Status.StorageClassName}, s.client, &storagev1.StorageClass{}) + if err != nil { + return nil, fmt.Errorf("get sc: %w", err) + } + + isWFFC := sc != nil && sc.VolumeBindingMode != nil && *sc.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer + if isWFFC && len(vd.Status.AttachedToVirtualMachines) != 1 { + vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.WaitingForFirstConsumer). + Message("Awaiting the creation and scheduling of the VirtualMachine with the attached VirtualDisk.") + return &reconcile.Result{}, nil + } + + if sc == nil { + return nil, fmt.Errorf("storage class %q not found", vd.Status.StorageClassName) + } + + nodePlacement, err := GetNodePlacement(ctx, s.client, vd) + if err != nil { + return nil, fmt.Errorf("failed to get importer tolerations: %w", err) + } + + // On a WaitForFirstConsumer storage class the imported volume must be + // provisioned on the VirtualMachine's node so it can be attached to the VM. + // Wait until the VM is scheduled (its node is known) before creating the + // target/prime PVCs; otherwise the prime would be provisioned on the + // importer pod's arbitrary node and the VM could never attach it. + if isWFFC && (nodePlacement == nil || nodePlacement.Node == "") { + vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.WaitingForFirstConsumer). + Message("Awaiting the scheduling of the VirtualMachine with the attached VirtualDisk.") + return &reconcile.Result{}, nil + } + + key := types.NamespacedName{Name: vd.Status.Target.PersistentVolumeClaim, Namespace: vd.Namespace} + if err := s.createTarget(ctx, key, sc.Name, vd, nodePlacement); err != nil { + if errors.Is(err, volumemode.ErrStorageProfileNotFound) { + vd.Status.Phase = v1alpha2.DiskFailed + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningFailed). + Message("StorageProfile not found in the cluster: Please check a StorageProfile name in the cluster.") + return &reconcile.Result{}, nil + } + // A project quota that rejects the target PVC is a recoverable, user-facing + // condition rather than a controller error: surface it on the Ready condition + // (and requeue via the resource-quota watcher) instead of returning an error + // that would be logged as a "Reconciler error". + if common.ErrQuotaExceeded(err) { + vd.Status.Phase = v1alpha2.DiskPending + vd.Status.Progress = "" + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.QuotaExceeded). + Message(fmt.Sprintf("Quota exceeded during the creation of the target PersistentVolumeClaim: %s", err)) + return &reconcile.Result{}, nil + } + return nil, fmt.Errorf("create target pvc: %w", err) + } + + return nil, nil +} + +func (s PVCImportStep) createTarget(ctx context.Context, key types.NamespacedName, storageClassName string, vd *v1alpha2.VirtualDisk, nodePlacement *provisioner.NodePlacement) error { + switch { + case s.source == nil: + return nil + case s.source.Registry != nil: + _, err := s.pvc.CreateTargetFromDVCR(ctx, key, storageClassName, &s.size, vd, s.source.Registry, s.disk, nodePlacement) + return err + case s.source.PVC != nil: + sourceClaim, err := object.FetchObject(ctx, types.NamespacedName{Name: s.source.PVC.Name, Namespace: s.source.PVC.Namespace}, s.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + return fmt.Errorf("fetch source pvc: %w", err) + } + if sourceClaim == nil { + return fmt.Errorf("source pvc %s/%s not found", s.source.PVC.Namespace, s.source.PVC.Name) + } + _, err = s.pvc.CreateTargetFromPVC(ctx, key, storageClassName, &s.size, vd, sourceClaim, s.disk, nodePlacement) + return err + default: + return nil + } +} + +func GetNodePlacement(ctx context.Context, c client.Client, vd *v1alpha2.VirtualDisk) (*provisioner.NodePlacement, error) { + if len(vd.Status.AttachedToVirtualMachines) != 1 { + return nil, nil + } + + vmKey := types.NamespacedName{Name: vd.Status.AttachedToVirtualMachines[0].Name, Namespace: vd.Namespace} + vm, err := object.FetchObject(ctx, vmKey, c, &v1alpha2.VirtualMachine{}) + if err != nil { + return nil, fmt.Errorf("unable to get the virtual machine %s: %w", vmKey, err) + } + + if vm == nil { + return nil, nil + } + + var nodePlacement provisioner.NodePlacement + // The node the VM is scheduled on (empty until the VM's virt-launcher pod is + // scheduled). Import helpers (prime PVC, importer pod) are pinned here so a + // WaitForFirstConsumer node-local volume is provisioned on the VM's node. + nodePlacement.Node = vm.Status.Node + nodePlacement.Tolerations = append(nodePlacement.Tolerations, vm.Spec.Tolerations...) + + vmClassKey := types.NamespacedName{Name: vm.Spec.VirtualMachineClassName} + vmClass, err := object.FetchObject(ctx, vmClassKey, c, &v1alpha2.VirtualMachineClass{}) + if err != nil { + return nil, fmt.Errorf("unable to get the virtual machine class %s: %w", vmClassKey, err) + } + + if vmClass == nil { + return &nodePlacement, nil + } + + nodePlacement.Tolerations = append(nodePlacement.Tolerations, vmClass.Spec.Tolerations...) + + return &nodePlacement, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_uploader_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_uploader_step.go new file mode 100644 index 0000000000..12c9705be5 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/create_uploader_step.go @@ -0,0 +1,144 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/datasource" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/controller/uploader" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +type CreateUploaderStepUploaderService interface { + Start(ctx context.Context, settings *uploader.Settings, obj client.Object, sup supplements.Generator, caBundle *datasource.CABundle, opts ...service.Option) error +} + +type CreateUploaderStep struct { + pvc *corev1.PersistentVolumeClaim + pod *corev1.Pod + svc *corev1.Service + ing *netv1.Ingress + uploader CreateUploaderStepUploaderService + dvcrSettings *dvcr.Settings + recorder eventrecord.EventRecorderLogger + cb *conditions.ConditionBuilder +} + +func NewCreateUploaderStep( + pvc *corev1.PersistentVolumeClaim, + pod *corev1.Pod, + svc *corev1.Service, + ing *netv1.Ingress, + uploader CreateUploaderStepUploaderService, + dvcrSettings *dvcr.Settings, + recorder eventrecord.EventRecorderLogger, + cb *conditions.ConditionBuilder, +) *CreateUploaderStep { + return &CreateUploaderStep{ + pvc: pvc, + pod: pod, + svc: svc, + ing: ing, + uploader: uploader, + dvcrSettings: dvcrSettings, + recorder: recorder, + cb: cb, + } +} + +func (s CreateUploaderStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + // Uploader is needed only until the underlying PVC is created. + // Once the PVC exists the data has been uploaded to DVCR and the uploader + // supplements are not recreated even if they are missing. + if s.pvc != nil { + return nil, nil + } + + if s.pod != nil && s.svc != nil && s.ing != nil { + return nil, nil + } + + s.recorder.Event( + vd, + corev1.EventTypeNormal, + v1alpha2.ReasonDataSourceSyncStarted, + "The Upload DataSource import to DVCR has started", + ) + + vd.Status.Progress = "0%" + + supgen := vdsupplements.NewGenerator(vd) + settings := s.getEnvSettings(vd, supgen) + + err := s.uploader.Start( + ctx, settings, vd, supgen, + datasource.NewCABundleForVMD(vd.GetNamespace(), vd.Spec.DataSource), + service.WithSystemNodeToleration(), + ) + switch { + case err == nil: + // OK. + case common.ErrQuotaExceeded(err): + s.recorder.Event(vd, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceQuotaExceeded, "DataSource quota exceed") + vd.Status.Phase = v1alpha2.DiskFailed + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.QuotaExceeded). + Message(fmt.Sprintf("Quota exceeded during the uploader provisioning: %s", err)) + return &reconcile.Result{}, nil + default: + return nil, fmt.Errorf("start uploader: %w", err) + } + + vd.Status.Phase = v1alpha2.DiskProvisioning + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message("DVCR Provisioner not found: create the new one.") + + return &reconcile.Result{RequeueAfter: time.Second}, nil +} + +func (s CreateUploaderStep) getEnvSettings(vd *v1alpha2.VirtualDisk, supgen supplements.Generator) *uploader.Settings { + var settings uploader.Settings + + uploader.ApplyDVCRDestinationSettings( + &settings, + s.dvcrSettings, + supgen, + s.dvcrSettings.RegistryImageForVD(vd), + ) + + return &settings +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/ensure_node_placement.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/ensure_node_placement.go deleted file mode 100644 index 120505af0f..0000000000 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/ensure_node_placement.go +++ /dev/null @@ -1,117 +0,0 @@ -/* -Copyright 2025 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package step - -import ( - "context" - "errors" - "fmt" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/service" - "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" - vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" -) - -type EnsureNodePlacementStepDiskService interface { - CheckProvisioning(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error - CleanUp(ctx context.Context, sup supplements.Generator) (bool, error) -} - -// EnsureNodePlacementStep supports changing the node placement only if the PVC is created using a DataVolume. -type EnsureNodePlacementStep struct { - pvc *corev1.PersistentVolumeClaim - dv *cdiv1.DataVolume - disk EnsureNodePlacementStepDiskService - client client.Client - cb *conditions.ConditionBuilder -} - -func NewEnsureNodePlacementStep( - pvc *corev1.PersistentVolumeClaim, - dv *cdiv1.DataVolume, - disk EnsureNodePlacementStepDiskService, - client client.Client, - cb *conditions.ConditionBuilder, -) *EnsureNodePlacementStep { - return &EnsureNodePlacementStep{ - pvc: pvc, - dv: dv, - disk: disk, - client: client, - cb: cb, - } -} - -func (s EnsureNodePlacementStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { - if s.pvc == nil { - return nil, nil - } - - err := s.disk.CheckProvisioning(ctx, s.pvc) - switch { - case err == nil: - // OK. - return nil, nil - case errors.Is(err, service.ErrDataVolumeProvisionerUnschedulable): - // Will be processed below. - default: - return nil, fmt.Errorf("check provisioning: %w", err) - } - - nodePlacement, err := GetNodePlacement(ctx, s.client, vd) - if err != nil { - return nil, fmt.Errorf("get node placement: %w", err) - } - - isChanged, err := provisioner.IsNodePlacementChanged(nodePlacement, s.dv) - if err != nil { - return nil, fmt.Errorf("is node placement changed: %w", err) - } - - vd.Status.Phase = v1alpha2.DiskProvisioning - - if !isChanged { - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.Provisioning). - Message("Trying to schedule the PersistentVolumeClaim provisioner.") - return &reconcile.Result{}, nil - } - - supgen := vdsupplements.NewGenerator(vd) - - _, err = s.disk.CleanUp(ctx, supgen) - if err != nil { - return nil, fmt.Errorf("clean up due to changes in the virtual machine tolerations: %w", err) - } - - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.Provisioning). - Message("The PersistentVolumeClaim provisioner will be recreated due to changes in the virtual machine tolerations.") - return &reconcile.Result{}, nil -} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/ready_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/ready_step.go index 1889ced6ce..da639da9f2 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/ready_step.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/ready_step.go @@ -22,8 +22,6 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" @@ -31,17 +29,13 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" - "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" ) -const readyStep = "ready" - type ReadyStepDiskService interface { GetCapacity(pvc *corev1.PersistentVolumeClaim) string CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) - Protect(ctx context.Context, sup supplements.Generator, owner client.Object, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error } type ReadyStep struct { @@ -63,8 +57,6 @@ func NewReadyStep( } func (s ReadyStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { - log := logger.FromContext(ctx).With(logger.SlogStep(readyStep)) - if s.pvc == nil { if vd.Status.Progress == "100%" { vd.Status.Phase = v1alpha2.DiskLost @@ -75,27 +67,31 @@ func (s ReadyStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconci return &reconcile.Result{}, nil } - log.Debug("PVC not created yet") return nil, nil } vdsupplements.SetPVCName(vd, s.pvc.Name) + if s.pvc.GetAnnotations()[annotations.AnnPVCPopulationStrategy] != "" && + s.pvc.GetAnnotations()[annotations.AnnPVCPopulationDone] != "true" { + return nil, nil + } switch s.pvc.Status.Phase { case corev1.ClaimLost: - s.cb.Status(metav1.ConditionFalse) if s.pvc.GetAnnotations()[annotations.AnnDataExportRequest] == "true" { vd.Status.Phase = v1alpha2.DiskExporting - s.cb.Reason(vdcondition.Exporting).Message("PV is being exported") + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Exporting). + Message("The VirtualDisk is being exported.") } else { vd.Status.Phase = v1alpha2.DiskLost s.cb. + Status(metav1.ConditionFalse). Reason(vdcondition.Lost). Message(fmt.Sprintf("The PersistentVolume %q not found.", s.pvc.Spec.VolumeName)) } - log.Debug("PVC is Lost") - return &reconcile.Result{}, nil case corev1.ClaimBound: vd.Status.Phase = v1alpha2.DiskReady @@ -106,25 +102,15 @@ func (s ReadyStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconci vd.Status.Progress = "100%" vd.Status.Capacity = s.diskService.GetCapacity(s.pvc) - log.Debug("PVC is Bound") - - supgen := vdsupplements.NewGenerator(vd) - err := s.diskService.Protect(ctx, supgen, vd, nil, s.pvc) - if err != nil { - return nil, fmt.Errorf("protect underlying pvc: %w", err) - } - if object.ShouldCleanupSubResources(vd) { - _, err = s.diskService.CleanUpSupplements(ctx, supgen) - if err != nil { + supgen := vdsupplements.NewGenerator(vd) + if _, err := s.diskService.CleanUpSupplements(ctx, supgen); err != nil { return nil, fmt.Errorf("clean up supplements: %w", err) } } return &reconcile.Result{}, nil default: - log.Debug("PVC not bound yet") - return nil, nil } } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/terminating_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/terminating_step.go index 910cf9f821..8070a026bc 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/terminating_step.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/terminating_step.go @@ -24,12 +24,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common/object" - "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) -const terminatingStep = "terminating" - type TerminatingStep struct { pvc *corev1.PersistentVolumeClaim } @@ -46,10 +43,6 @@ func (s TerminatingStep) Take(ctx context.Context, _ *v1alpha2.VirtualDisk) (*re } if object.IsTerminating(s.pvc) { - log := logger.FromContext(ctx).With(logger.SlogStep(terminatingStep)) - - log.Debug("Wait until PVC will be terminated") - return &reconcile.Result{RequeueAfter: 2 * time.Second}, nil } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_dv_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_dv_step.go deleted file mode 100644 index 667be675e0..0000000000 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_dv_step.go +++ /dev/null @@ -1,230 +0,0 @@ -/* -Copyright 2025 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package step - -import ( - "context" - "fmt" - "strings" - - corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - dvutil "github.com/deckhouse/virtualization-controller/pkg/common/datavolume" - "github.com/deckhouse/virtualization-controller/pkg/common/object" - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/service" - vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" -) - -type WaitForDVStepDiskService interface { - GetProgress(dv *cdiv1.DataVolume, prevProgress string, opts ...service.GetProgressOption) string -} - -type WaitForDVStep struct { - pvc *corev1.PersistentVolumeClaim - dv *cdiv1.DataVolume - disk WaitForDVStepDiskService - client client.Client - cb *conditions.ConditionBuilder -} - -func NewWaitForDVStep( - pvc *corev1.PersistentVolumeClaim, - dv *cdiv1.DataVolume, - disk WaitForDVStepDiskService, - client client.Client, - cb *conditions.ConditionBuilder, -) *WaitForDVStep { - return &WaitForDVStep{ - pvc: pvc, - dv: dv, - disk: disk, - client: client, - cb: cb, - } -} - -func (s WaitForDVStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { - if s.dv == nil { - vd.Status.Phase = v1alpha2.DiskProvisioning - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.Provisioning). - Message("Waiting for the VirtualDisk importer to be created.") - return &reconcile.Result{}, nil - } - - vd.Status.Progress = s.disk.GetProgress(s.dv, vd.Status.Progress, service.NewScaleOption(0, 100)) - vdsupplements.SetPVCName(vd, s.dv.Status.ClaimName) - - set, err := s.setForFirstConsumerIsAwaited(ctx, vd) - if err != nil { - return nil, fmt.Errorf("set for first consumer is awaited: %w", err) - } - ok := s.checkQoutaNotExceededCondition(vd, set) - if !ok { - return &reconcile.Result{}, nil - } - if set { - return &reconcile.Result{}, nil - } - - ok = s.checkRunningCondition(vd) - if !ok { - return &reconcile.Result{}, nil - } - - ok, err = s.checkImporterPrimePod(ctx, vd) - if err != nil { - return nil, fmt.Errorf("check importer prime pod: %w", err) - } - if !ok { - return &reconcile.Result{}, nil - } - - set = s.setForProvisioning(vd) - if set { - return &reconcile.Result{}, nil - } - - return nil, nil -} - -func (s WaitForDVStep) setForProvisioning(vd *v1alpha2.VirtualDisk) (set bool) { - if s.dv.Status.Phase != cdiv1.Succeeded { - vd.Status.Phase = v1alpha2.DiskProvisioning - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.Provisioning). - Message("Import is in the process of provisioning to the PersistentVolumeClaim.") - return true - } - - return false -} - -func (s WaitForDVStep) setForFirstConsumerIsAwaited(ctx context.Context, vd *v1alpha2.VirtualDisk) (set bool, err error) { - if vd.Status.StorageClassName == "" { - return false, fmt.Errorf("StorageClassName is empty, please report a bug") - } - - sc, err := object.FetchObject(ctx, types.NamespacedName{Name: vd.Status.StorageClassName}, s.client, &storagev1.StorageClass{}) - if err != nil { - return false, fmt.Errorf("get sc: %w", err) - } - - isWFFC := sc != nil && sc.VolumeBindingMode != nil && *sc.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer - dvRunningCond, _ := conditions.GetDataVolumeCondition(conditions.DVRunningConditionType, s.dv.Status.Conditions) - dvRunningReasonEmptyOrPending := dvRunningCond.Reason == "" || dvRunningCond.Reason == conditions.DVRunningConditionPendingReason - if isWFFC && (s.dv.Status.Phase == cdiv1.PendingPopulation || s.dv.Status.Phase == cdiv1.WaitForFirstConsumer) && dvRunningReasonEmptyOrPending { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.WaitingForFirstConsumer). - Message("The provisioning has been suspended: a created and scheduled virtual machine is awaited.") - return true, nil - } - - return false, nil -} - -func (s WaitForDVStep) checkQoutaNotExceededCondition(vd *v1alpha2.VirtualDisk, inwffc bool) (ok bool) { - dvQuotaNotExceededCondition, _ := conditions.GetDataVolumeCondition(conditions.DVQoutaNotExceededConditionType, s.dv.Status.Conditions) - if dvQuotaNotExceededCondition.Status == corev1.ConditionFalse { - vd.Status.Phase = v1alpha2.DiskPending - if inwffc { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - } - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.QuotaExceeded). - Message(dvQuotaNotExceededCondition.Message) - return false - } - - return true -} - -func (s WaitForDVStep) checkRunningCondition(vd *v1alpha2.VirtualDisk) (ok bool) { - dvRunningCondition, _ := conditions.GetDataVolumeCondition(conditions.DVRunningConditionType, s.dv.Status.Conditions) - switch { - case dvRunningCondition.Reason == conditions.DVImagePullFailedReason: - vd.Status.Phase = v1alpha2.DiskFailed - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.ImagePullFailed). - Message(dvRunningCondition.Message) - return false - case strings.Contains(dvRunningCondition.Reason, "Error"): - vd.Status.Phase = v1alpha2.DiskFailed - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.ProvisioningFailed). - Message(dvRunningCondition.Message) - return false - default: - return true - } -} - -func (s WaitForDVStep) checkImporterPrimePod(ctx context.Context, vd *v1alpha2.VirtualDisk) (ok bool, err error) { - if s.pvc == nil { - return true, nil - } - - cdiImporterPrimeKey := types.NamespacedName{ - Namespace: s.pvc.Namespace, - Name: dvutil.GetImporterPrimeName(s.pvc.UID), - } - - cdiImporterPrime, err := object.FetchObject(ctx, cdiImporterPrimeKey, s.client, &corev1.Pod{}) - if err != nil { - return false, fmt.Errorf("fetch importer prime pod: %w", err) - } - - if cdiImporterPrime != nil { - podInitializedCond, _ := conditions.GetPodCondition(corev1.PodInitialized, cdiImporterPrime.Status.Conditions) - if podInitializedCond.Status == corev1.ConditionFalse && strings.Contains(podInitializedCond.Reason, "Error") { - vd.Status.Phase = v1alpha2.DiskPending - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.ImagePullFailed). - Message(fmt.Sprintf("The PVC importer is not initialized; %s error %s: %s", cdiImporterPrimeKey.String(), podInitializedCond.Reason, podInitializedCond.Message)) - return false, nil - } - - podScheduledCond, _ := conditions.GetPodCondition(corev1.PodScheduled, cdiImporterPrime.Status.Conditions) - if podScheduledCond.Status == corev1.ConditionFalse && strings.Contains(podScheduledCond.Reason, "Error") { - vd.Status.Phase = v1alpha2.DiskPending - s.cb. - Status(metav1.ConditionFalse). - Reason(vdcondition.ImagePullFailed). - Message(fmt.Sprintf("The PVC importer is not scheduled; %s error %s: %s", cdiImporterPrimeKey.String(), podScheduledCond.Reason, podScheduledCond.Message)) - return false, nil - } - } - - return true, nil -} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_dvcr_importer_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_dvcr_importer_step.go new file mode 100644 index 0000000000..bfc5c14ecb --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_dvcr_importer_step.go @@ -0,0 +1,159 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "errors" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +type WaitForDVCRImporterStepStatService interface { + CheckPod(pod *corev1.Pod) error + GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string + GetDownloadSpeed(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed +} + +type WaitForDVCRImporterStepImporterService interface { + Protect(ctx context.Context, pod *corev1.Pod, sup supplements.Generator) error +} + +// WaitForDVCRImporterStep tracks an importer Pod that downloads source data into +// DVCR. It is a no-op while the Pod is missing or has already completed, in +// which case downstream steps take over. +type WaitForDVCRImporterStep struct { + pod *corev1.Pod + stat WaitForDVCRImporterStepStatService + importer WaitForDVCRImporterStepImporterService + client client.Client + cb *conditions.ConditionBuilder +} + +func NewWaitForDVCRImporterStep( + pod *corev1.Pod, + stat WaitForDVCRImporterStepStatService, + importer WaitForDVCRImporterStepImporterService, + client client.Client, + cb *conditions.ConditionBuilder, +) *WaitForDVCRImporterStep { + return &WaitForDVCRImporterStep{ + pod: pod, + stat: stat, + importer: importer, + client: client, + cb: cb, + } +} + +func (s WaitForDVCRImporterStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if s.pod == nil || podutil.IsPodComplete(s.pod) { + return nil, nil + } + + if err := s.stat.CheckPod(s.pod); err != nil { + return s.handlePodError(ctx, vd, err) + } + + vd.Status.Phase = v1alpha2.DiskProvisioning + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message("Import is in the process of provisioning to DVCR.") + + vd.Status.Progress = s.stat.GetProgress(vd.GetUID(), s.pod, vd.Status.Progress, service.NewScaleOption(0, 50)) + vd.Status.DownloadSpeed = s.stat.GetDownloadSpeed(vd.GetUID(), s.pod) + + supgen := vdsupplements.NewGenerator(vd) + if err := s.importer.Protect(ctx, s.pod, supgen); err != nil { + return nil, fmt.Errorf("protect importer pod: %w", err) + } + + return &reconcile.Result{RequeueAfter: 2 * time.Second}, nil +} + +func (s WaitForDVCRImporterStep) handlePodError(ctx context.Context, vd *v1alpha2.VirtualDisk, podErr error) (*reconcile.Result, error) { + switch { + case errors.Is(podErr, service.ErrNotInitialized): + vd.Status.Phase = v1alpha2.DiskFailed + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningNotStarted). + Message(service.CapitalizeFirstLetter(podErr.Error()) + ".") + return &reconcile.Result{}, nil + case errors.Is(podErr, service.ErrNotScheduled): + vd.Status.Phase = v1alpha2.DiskPending + vd.Status.Progress = "" + + nodePlacement, err := GetNodePlacement(ctx, s.client, vd) + if err != nil { + return nil, fmt.Errorf("get node placement: %w", err) + } + + isChanged, err := provisioner.IsNodePlacementChanged(nodePlacement, s.pod) + if err != nil { + return nil, fmt.Errorf("check node placement: %w", err) + } + + if isChanged { + if err := s.client.Delete(ctx, s.pod); err != nil { + return nil, fmt.Errorf("recreate importer pod: %w", err) + } + + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningNotStarted). + Message("Provisioner recreation due to a changes in the virtual machine tolerations.") + } else { + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningNotStarted). + Message(service.CapitalizeFirstLetter(podErr.Error()) + ".") + } + + return &reconcile.Result{}, nil + case errors.Is(podErr, service.ErrProvisioningFailed): + vd.Status.Phase = v1alpha2.DiskFailed + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningFailed). + Message(service.CapitalizeFirstLetter(podErr.Error()) + ".") + return &reconcile.Result{}, nil + default: + vd.Status.Phase = v1alpha2.DiskFailed + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningFailed). + Message(service.CapitalizeFirstLetter(fmt.Errorf("unexpected error: %w", podErr).Error()) + ".") + return nil, podErr + } +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_pvc_import_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_pvc_import_step.go new file mode 100644 index 0000000000..56873c9ea3 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_pvc_import_step.go @@ -0,0 +1,257 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +// pvcImportProgressRequeue is how often the step refreshes vd.Status.Progress +// from the pvc-importer pod metrics while the import is in flight. +const pvcImportProgressRequeue = 2 * time.Second + +// WaitForPVCImportStepStatService is the subset of StatService used to extract +// the pvc-importer pod's progress and project it into vd.Status.Progress. +type WaitForPVCImportStepStatService interface { + GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string +} + +// PVCImportSourceProvider builds the PVCImportSource used by WaitForPVCImportStep. +// It is invoked lazily so the source can be derived from objects (Pod, CVI, VI) +// inside the step instead of being constructed by the data source up front. +type PVCImportSourceProvider func(ctx context.Context, vd *v1alpha2.VirtualDisk) (*service.PVCImportSource, error) + +// WaitForPVCImportStep drives the import of data from DVCR (or another +// PVCImportSource) into the target PersistentVolumeClaim and reflects its +// progress in the VirtualDisk status. It is a no-op until the PVC has been +// created and reached the Bound phase. +// +// While the import is running the step requeues every pvcImportProgressRequeue +// and republishes vd.Status.Progress from the pvc-importer pod's +// kubevirt_cdi_import_progress_total metric (0..100). When progressScale is +// set, the value is projected into the [progressScale.Low, progressScale.High] +// slice of the disk-wide progress (e.g. 50..100 for HTTP / Registry / Upload +// data sources where the first 50% is already filled by the DVCR phase). +type WaitForPVCImportStep struct { + pvc *corev1.PersistentVolumeClaim + sourceProvider PVCImportSourceProvider + pvcSvc PVCService + stat WaitForPVCImportStepStatService + progressScale *service.ScaleOption + client client.Client + cb *conditions.ConditionBuilder +} + +func NewWaitForPVCImportStep( + pvc *corev1.PersistentVolumeClaim, + sourceProvider PVCImportSourceProvider, + pvcSvc PVCService, + stat WaitForPVCImportStepStatService, + progressScale *service.ScaleOption, + client client.Client, + cb *conditions.ConditionBuilder, +) *WaitForPVCImportStep { + return &WaitForPVCImportStep{ + pvc: pvc, + sourceProvider: sourceProvider, + pvcSvc: pvcSvc, + stat: stat, + progressScale: progressScale, + client: client, + cb: cb, + } +} + +func (s WaitForPVCImportStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if vd.Status.Progress == "" { + vd.Status.Progress = "0%" + } + + if s.pvc == nil { + vd.Status.Phase = v1alpha2.DiskProvisioning + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message("Waiting for the underlying PersistentVolumeClaim to be created by controller.") + return &reconcile.Result{}, nil + } + + // Note: the target PVC is intentionally NOT required to be Bound here. The + // importer fills a separate prime PVC and its volume is rebound to the target on + // completion (see PVCImporterService.Wait/Rebind), so the target only becomes + // Bound at the very end of the import. Gating the import on the target being + // Bound would deadlock (the target waits for the rebind, the rebind waits for the + // import). The import and the rebind are driven by Import/WaitForImport below. + + phase, err := s.pvcImportPodPhase(ctx, vd) + if err != nil { + return nil, err + } + + switch phase { + case corev1.PodSucceeded: + return &reconcile.Result{RequeueAfter: time.Second}, nil + case corev1.PodFailed: + vd.Status.Phase = v1alpha2.DiskFailed + s.cb.Status(metav1.ConditionFalse).Reason(vdcondition.ProvisioningFailed).Message("VirtualDisk importer Pod failed.") + return &reconcile.Result{}, nil + default: + vd.Status.Phase = v1alpha2.DiskProvisioning + s.cb.Status(metav1.ConditionFalse).Reason(vdcondition.Provisioning).Message("Import is in the process of provisioning to the PersistentVolumeClaim.") + + if err := s.refreshProgressFromPod(ctx, vd); err != nil { + return nil, err + } + + return &reconcile.Result{RequeueAfter: pvcImportProgressRequeue}, nil + } +} + +func (s WaitForPVCImportStep) pvcImportPodPhase(ctx context.Context, vd *v1alpha2.VirtualDisk) (corev1.PodPhase, error) { + if s.pvc.Annotations[annotations.AnnPVCPopulationDone] == "true" { + return corev1.PodSucceeded, nil + } + pod, err := s.fetchPVCImportPod(ctx, vd) + if err != nil { + return "", fmt.Errorf("fetch pvc-importer pod: %w", err) + } + if pod == nil || pod.Status.Phase == "" { + return corev1.PodPending, nil + } + return pod.Status.Phase, nil +} + +// refreshProgressFromPod queries the pvc-importer pod (named after the target +// PVC) for its progress metric and updates vd.Status.Progress. Silently keeps +// the previous value when stat/pod is missing or metrics are not yet readable. +func (s WaitForPVCImportStep) refreshProgressFromPod(ctx context.Context, vd *v1alpha2.VirtualDisk) error { + if s.stat == nil { + return nil + } + + pod, err := s.fetchPVCImportPod(ctx, vd) + if err != nil { + return fmt.Errorf("fetch pvc-importer pod: %w", err) + } + if pod == nil { + return nil + } + + var opts []service.GetProgressOption + if s.progressScale != nil { + opts = append(opts, s.progressScale) + } + vd.Status.Progress = s.stat.GetProgress(vd.GetUID(), pod, vd.Status.Progress, opts...) + vd.Status.Progress = service.CapProgressBelow(vd.Status.Progress, 100) + return nil +} + +func (s WaitForPVCImportStep) fetchPVCImportPod(ctx context.Context, vd *v1alpha2.VirtualDisk) (*corev1.Pod, error) { + sup := vdsupplements.NewGenerator(vd) + if s.pvc.Annotations[annotations.AnnPVCPopulationStrategy] == service.PopulationStrategyHostAssigned { + return object.FetchObject(ctx, types.NamespacedName{Name: sup.PVCTargetImporterPod().Name, Namespace: s.pvc.Namespace}, s.client, &corev1.Pod{}) + } + return object.FetchObject(ctx, types.NamespacedName{Name: sup.PVCImporterPod().Name, Namespace: s.pvc.Namespace}, s.client, &corev1.Pod{}) +} + +// StaticPVCImportSource returns a PVCImportSourceProvider that always returns +// the given source. It is useful when the source is fully known up front, e.g. +// for ObjectRef-based data sources. +func StaticPVCImportSource(source *service.PVCImportSource) PVCImportSourceProvider { + return func(_ context.Context, _ *v1alpha2.VirtualDisk) (*service.PVCImportSource, error) { + return source, nil + } +} + +// DVCRPodPVCImportSource returns a PVCImportSourceProvider that builds a +// registry-backed PVCImportSource using the DVCR image name resolved from the +// provided helper Pod (uploader or importer). +func DVCRPodPVCImportSource(pod *corev1.Pod, stat interface { + GetDVCRImageName(pod *corev1.Pod) string +}, +) PVCImportSourceProvider { + return func(_ context.Context, vd *v1alpha2.VirtualDisk) (*service.PVCImportSource, error) { + if pod == nil { + return nil, nil + } + dvcrImageName := stat.GetDVCRImageName(pod) + if dvcrImageName == "" { + return nil, nil + } + return BuildDVCRPVCImportSource(vd, dvcrImageName), nil + } +} + +// ClusterVirtualImagePVCImportSource returns a PVCImportSourceProvider that +// fetches the referenced ClusterVirtualImage on demand and builds a +// registry-backed PVCImportSource. The fetch happens only when the step +// actually needs the source (i.e. while the import is in flight), so the data +// source handler does not have to fetch the CVI on every reconcile. +func ClusterVirtualImagePVCImportSource(c client.Client) PVCImportSourceProvider { + return func(ctx context.Context, vd *v1alpha2.VirtualDisk) (*service.PVCImportSource, error) { + if vd.Spec.DataSource == nil || vd.Spec.DataSource.ObjectRef == nil { + return nil, nil + } + key := types.NamespacedName{Name: vd.Spec.DataSource.ObjectRef.Name} + cviRef, err := object.FetchObject(ctx, key, c, &v1alpha2.ClusterVirtualImage{}) + if err != nil { + return nil, fmt.Errorf("fetch cvi %q: %w", key, err) + } + if cviRef == nil { + return nil, nil + } + return BuildClusterVirtualImagePVCImportSource(vd, cviRef), nil + } +} + +// VirtualImagePVCImportSource returns a PVCImportSourceProvider that fetches +// the referenced VirtualImage on demand and builds the appropriate +// PVCImportSource (registry-backed for DVCR storage, PVC-backed for in-cluster +// PVC storage). The fetch happens only when the step actually needs the +// source. +func VirtualImagePVCImportSource(c client.Client) PVCImportSourceProvider { + return func(ctx context.Context, vd *v1alpha2.VirtualDisk) (*service.PVCImportSource, error) { + if vd.Spec.DataSource == nil || vd.Spec.DataSource.ObjectRef == nil { + return nil, nil + } + key := types.NamespacedName{Name: vd.Spec.DataSource.ObjectRef.Name, Namespace: vd.Namespace} + viRef, err := object.FetchObject(ctx, key, c, &v1alpha2.VirtualImage{}) + if err != nil { + return nil, fmt.Errorf("fetch vi %q: %w", key, err) + } + if viRef == nil { + return nil, nil + } + return BuildVirtualImagePVCImportSource(vd, viRef) + } +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_pvc_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_pvc_step.go index 538b7f1e15..2382638cfc 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_pvc_step.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_pvc_step.go @@ -52,6 +52,10 @@ func NewWaitForPVCStep( } func (s WaitForPVCStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if vd.Status.Progress == "" { + vd.Status.Progress = "0%" + } + if s.pvc == nil { vd.Status.Phase = v1alpha2.DiskProvisioning s.cb. diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_user_upload_step.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_user_upload_step.go new file mode 100644 index 0000000000..9957aa9a29 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/step/wait_for_user_upload_step.go @@ -0,0 +1,228 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "errors" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +type WaitForUserUploadStepStatService interface { + CheckPod(pod *corev1.Pod) error + IsUploadStarted(ownerUID types.UID, pod *corev1.Pod) bool + IsUploaderReady(pod *corev1.Pod, svc *corev1.Service, ing *netv1.Ingress, tlsSecret *corev1.Secret) (bool, error) +} + +type WaitForDVCRUploaderStepStatService interface { + CheckPod(pod *corev1.Pod) error + IsUploadStarted(ownerUID types.UID, pod *corev1.Pod) bool + GetProgress(ownerUID types.UID, pod *corev1.Pod, prevProgress string, opts ...service.GetProgressOption) string + GetDownloadSpeed(ownerUID types.UID, pod *corev1.Pod) *v1alpha2.StatusSpeed +} + +type WaitForUserUploadStepUploaderService interface { + GetExternalURL(ctx context.Context, ing *netv1.Ingress) string + GetInClusterURL(ctx context.Context, svc *corev1.Service) string +} + +type WaitForUserUploadStep struct { + pod *corev1.Pod + svc *corev1.Service + ing *netv1.Ingress + stat WaitForUserUploadStepStatService + uploader WaitForUserUploadStepUploaderService + client client.Client + cb *conditions.ConditionBuilder +} + +func NewWaitForUserUploadStep( + pod *corev1.Pod, + svc *corev1.Service, + ing *netv1.Ingress, + stat WaitForUserUploadStepStatService, + uploader WaitForUserUploadStepUploaderService, + client client.Client, + cb *conditions.ConditionBuilder, +) *WaitForUserUploadStep { + return &WaitForUserUploadStep{ + pod: pod, + svc: svc, + ing: ing, + stat: stat, + uploader: uploader, + client: client, + cb: cb, + } +} + +func (s WaitForUserUploadStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if s.pod == nil || podutil.IsPodComplete(s.pod) { + return nil, nil + } + + supgen := vdsupplements.NewGenerator(vd) + uploadStarted := s.stat.IsUploadStarted(vd.GetUID(), s.pod) || hasUploadProgress(vd.Status.Progress) + if uploadStarted { + return nil, nil + } + + if err := s.stat.CheckPod(s.pod); err != nil { + return s.handlePodError(ctx, vd, err) + } + + tlsSecret, err := supplements.GetTLSSecret(ctx, s.client, supgen.Generator) + if err != nil { + return nil, fmt.Errorf("fetch uploader tls secret: %w", err) + } + + isUploaderReady, err := s.stat.IsUploaderReady(s.pod, s.svc, s.ing, tlsSecret) + if err != nil { + return nil, fmt.Errorf("check uploader readiness: %w", err) + } + + if isUploaderReady { + vd.Status.Phase = v1alpha2.DiskWaitForUserUpload + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.WaitForUserUpload). + Message("Waiting for the user upload.") + vd.Status.ImageUploadURLs = &v1alpha2.ImageUploadURLs{ + External: s.uploader.GetExternalURL(ctx, s.ing), + InCluster: s.uploader.GetInClusterURL(ctx, s.svc), + } + } else { + vd.Status.Phase = v1alpha2.DiskProvisioning + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message(fmt.Sprintf("Waiting for the uploader %q to be ready to process the user's upload.", s.pod.Name)) + } + + return &reconcile.Result{RequeueAfter: time.Second}, nil +} + +type WaitForDVCRUploaderStep struct { + pod *corev1.Pod + stat WaitForDVCRUploaderStepStatService + cb *conditions.ConditionBuilder +} + +func NewWaitForDVCRUploaderStep( + pod *corev1.Pod, + stat WaitForDVCRUploaderStepStatService, + cb *conditions.ConditionBuilder, +) *WaitForDVCRUploaderStep { + return &WaitForDVCRUploaderStep{ + pod: pod, + stat: stat, + cb: cb, + } +} + +func (s WaitForDVCRUploaderStep) Take(ctx context.Context, vd *v1alpha2.VirtualDisk) (*reconcile.Result, error) { + if s.pod == nil || podutil.IsPodComplete(s.pod) { + return nil, nil + } + + // Reaching this step means the preceding WaitForUserUploadStep has already + // let the pipeline through, i.e. the user upload has started and the + // uploader pod is running. We deliberately do not re-check IsUploadStarted + // here: it issues another best-effort metrics scrape that, under upload + // load, frequently times out and would make this step bail out before it + // can report progress, keeping vd.Status.Progress stuck at 0% until the pod + // succeeds. Instead, keep requeueing every second and report the live + // progress metric whenever the scrape succeeds. + if err := s.stat.CheckPod(s.pod); err != nil { + return handleUploaderPodError(vd, err, s.cb, true) + } + + vd.Status.Phase = v1alpha2.DiskProvisioning + s.cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message("Import is in the process of provisioning to DVCR.") + + vd.Status.Progress = s.stat.GetProgress(vd.GetUID(), s.pod, vd.Status.Progress, service.NewScaleOption(0, 50)) + vd.Status.DownloadSpeed = s.stat.GetDownloadSpeed(vd.GetUID(), s.pod) + + return &reconcile.Result{RequeueAfter: time.Second}, nil +} + +func (s WaitForUserUploadStep) handlePodError(_ context.Context, vd *v1alpha2.VirtualDisk, podErr error) (*reconcile.Result, error) { + return handleUploaderPodError(vd, podErr, s.cb, false) +} + +func handleUploaderPodError(vd *v1alpha2.VirtualDisk, podErr error, cb *conditions.ConditionBuilder, uploadStarted bool) (*reconcile.Result, error) { + switch { + case errors.Is(podErr, service.ErrNotInitialized), errors.Is(podErr, service.ErrNotScheduled): + if uploadStarted { + vd.Status.Phase = v1alpha2.DiskProvisioning + cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message("Import is in the process of provisioning to DVCR.") + return &reconcile.Result{RequeueAfter: time.Second}, nil + } + + vd.Status.Phase = v1alpha2.DiskProvisioning + cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.Provisioning). + Message(service.CapitalizeFirstLetter(podErr.Error()) + ".") + return &reconcile.Result{}, nil + case errors.Is(podErr, service.ErrProvisioningFailed): + vd.Status.Phase = v1alpha2.DiskFailed + cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningFailed). + Message(service.CapitalizeFirstLetter(podErr.Error()) + ".") + return &reconcile.Result{}, nil + default: + vd.Status.Phase = v1alpha2.DiskFailed + cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.ProvisioningFailed). + Message(service.CapitalizeFirstLetter(fmt.Errorf("unexpected error: %w", podErr).Error()) + ".") + return nil, podErr + } +} + +func hasUploadProgress(progress string) bool { + switch progress { + case "", "0", "0%", "0.0%", "0.00%": + return false + default: + return true + } +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/upload.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/upload.go index a13fbbb404..a3be9533e1 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/source/upload.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/upload.go @@ -18,29 +18,15 @@ package source import ( "context" - "errors" "fmt" - "time" - corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/virtualization-controller/pkg/common" - "github.com/deckhouse/virtualization-controller/pkg/common/datasource" - "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" - "github.com/deckhouse/virtualization-controller/pkg/common/object" - podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" - "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/common/steptaker" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/service" - "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" - "github.com/deckhouse/virtualization-controller/pkg/controller/uploader" + "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source/step" vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" "github.com/deckhouse/virtualization-controller/pkg/dvcr" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -52,9 +38,10 @@ import ( const uploadDataSource = "upload" type UploadDataSource struct { - statService *service.StatService - uploaderService *service.UploaderService - diskService *service.DiskService + statService UploadDataSourceStatService + uploaderService UploadDataSourceUploaderService + diskService UploadDataSourceDiskService + pvcService DataSourcePVCService dvcrSettings *dvcr.Settings recorder eventrecord.EventRecorderLogger client client.Client @@ -62,9 +49,10 @@ type UploadDataSource struct { func NewUploadDataSource( recorder eventrecord.EventRecorderLogger, - statService *service.StatService, - uploaderService *service.UploaderService, - diskService *service.DiskService, + statService UploadDataSourceStatService, + uploaderService UploadDataSourceUploaderService, + diskService UploadDataSourceDiskService, + pvcService DataSourcePVCService, dvcrSettings *dvcr.Settings, client client.Client, ) *UploadDataSource { @@ -72,6 +60,7 @@ func NewUploadDataSource( statService: statService, uploaderService: uploaderService, diskService: diskService, + pvcService: pvcService, dvcrSettings: dvcrSettings, client: client, recorder: recorder, @@ -81,299 +70,42 @@ func NewUploadDataSource( func (ds UploadDataSource) Sync(ctx context.Context, vd *v1alpha2.VirtualDisk) (reconcile.Result, error) { log, ctx := logger.GetDataSourceContext(ctx, uploadDataSource) - condition, _ := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions) + supgen := vdsupplements.NewGenerator(vd) + cb := conditions.NewConditionBuilder(vdcondition.ReadyType).Generation(vd.Generation) defer func() { conditions.SetCondition(cb, &vd.Status.Conditions) }() - supgen := vdsupplements.NewGenerator(vd) - pod, err := ds.uploaderService.GetPod(ctx, supgen) if err != nil { - return reconcile.Result{}, err + return reconcile.Result{}, fmt.Errorf("fetch uploader pod: %w", err) } svc, err := ds.uploaderService.GetService(ctx, supgen) if err != nil { - return reconcile.Result{}, err + return reconcile.Result{}, fmt.Errorf("fetch uploader service: %w", err) } ing, err := ds.uploaderService.GetIngress(ctx, supgen) if err != nil { - return reconcile.Result{}, err - } - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vd.Status.StorageClassName) - if err != nil { - return reconcile.Result{}, err - } - - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) - vdsupplements.SetPVCName(vd, dv.Status.ClaimName) - } - - tlsSecret, err := supplements.GetTLSSecret(ctx, ds.client, supgen.Generator) - if err != nil { - return reconcile.Result{}, err + return reconcile.Result{}, fmt.Errorf("fetch uploader ingress: %w", err) } - isUploaderReady, err := ds.statService.IsUploaderReady(pod, svc, ing, tlsSecret) + pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { - return reconcile.Result{}, err - } - - switch { - case IsDiskProvisioningFinished(condition): - log.Debug("Disk provisioning finished: clean up") - - setPhaseConditionForFinishedDisk(vd, pvc, cb, &vd.Status.Phase, supgen) - - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen.Generator, vd, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - - // Unprotect upload time supplements to delete them later. - err = ds.uploaderService.Unprotect(ctx, supgen, pod, svc, ing) - if err != nil { - return reconcile.Result{}, err - } - - err = ds.diskService.Unprotect(ctx, supgen, dv) - if err != nil { - return reconcile.Result{}, err - } - - return CleanUpSupplements(ctx, vd, ds) - case object.AnyTerminating(pod, svc, ing, dv, pvc): - log.Info("Waiting for supplements to be terminated") - case pod == nil || svc == nil || ing == nil: - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncStarted, - "The Upload DataSource import to DVCR has started", - ) - - vd.Status.Progress = "0%" - - envSettings := ds.getEnvSettings(vd, supgen.Generator) - - err = ds.uploaderService.Start( - ctx, envSettings, vd, supgen, - datasource.NewCABundleForVMD(vd.GetNamespace(), vd.Spec.DataSource), - service.WithSystemNodeToleration(), - ) - switch { - case err == nil: - // OK. - case common.ErrQuotaExceeded(err): - ds.recorder.Event(vd, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceQuotaExceeded, "DataSource quota exceed") - return setQuotaExceededPhaseCondition(vd, cb, &vd.Status.Phase, err, vd.CreationTimestamp), nil - default: - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return reconcile.Result{}, err - } - - vd.Status.Phase = v1alpha2.DiskPending - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.WaitForUserUpload, "DVCR Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case !podutil.IsPodComplete(pod): - err = ds.statService.CheckPod(pod) - if err != nil { - return reconcile.Result{}, setPhaseConditionFromPodError(ctx, err, pod, vd, cb, ds.client) - } - - if !ds.statService.IsUploadStarted(vd.GetUID(), pod) { - if isUploaderReady { - log.Info("Waiting for the user upload", "pod.phase", pod.Status.Phase) - - vd.Status.Phase = v1alpha2.DiskWaitForUserUpload - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.WaitForUserUpload, "Waiting for the user upload.") - vd.Status.ImageUploadURLs = &v1alpha2.ImageUploadURLs{ - External: ds.uploaderService.GetExternalURL(ctx, ing), - InCluster: ds.uploaderService.GetInClusterURL(ctx, svc), - } - } else { - log.Info("Waiting for the uploader to be ready to process the user's upload", "pod.phase", pod.Status.Phase) - - vd.Status.Phase = v1alpha2.DiskPending - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningNotStarted, fmt.Sprintf("Waiting for the uploader %q to be ready to process the user's upload.", pod.Name)) - } - - return reconcile.Result{RequeueAfter: time.Second}, nil - } - - log.Info("Provisioning to DVCR is in progress", "podPhase", pod.Status.Phase) - - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "Import is in the process of provisioning to DVCR.") - - vd.Status.Progress = ds.statService.GetProgress(vd.GetUID(), pod, vd.Status.Progress, service.NewScaleOption(0, 50)) - vd.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vd.GetUID(), pod) - - err = ds.uploaderService.Protect(ctx, supgen, pod, svc, ing) - if err != nil { - return reconcile.Result{}, err - } - case dv == nil: - if isStorageClassWFFC(sc) && len(vd.Status.AttachedToVirtualMachines) != 1 { - vd.Status.Progress = "50%" - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - setReadyConditionWithWFFCAccounting( - vd, - cb, - metav1.ConditionFalse, - vdcondition.WaitingForFirstConsumer, - "The provisioning has been suspended: a created and scheduled virtual machine is awaited.", - ) - return reconcile.Result{}, nil - } - - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncStarted, - "The Upload DataSource import to PVC has started", - ) - - err = ds.statService.CheckPod(pod) - if err != nil { - vd.Status.Phase = v1alpha2.DiskFailed - - switch { - case errors.Is(err, service.ErrProvisioningFailed): - ds.recorder.Event(vd, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceDiskProvisioningFailed, "Disk provisioning failed") - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ProvisioningFailed, service.CapitalizeFirstLetter(err.Error()+".")) - return reconcile.Result{}, nil - default: - return reconcile.Result{}, err - } - } - - vd.Status.Progress = "50%" - vd.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vd.GetUID(), pod) - - if imageformat.IsISO(ds.statService.GetFormat(pod)) { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, ErrISOSourceNotSupported) - return reconcile.Result{}, nil - } - - var diskSize resource.Quantity - diskSize, err = ds.getPVCSize(vd, pod) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, err) - - if errors.Is(err, service.ErrInsufficientPVCSize) { - return reconcile.Result{}, nil - } - - return reconcile.Result{}, err - } - - source := ds.getSource(supgen.Generator, ds.statService.GetDVCRImageName(pod)) - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vd.Status.StorageClassName) - if err != nil { - return reconcile.Result{}, err - } - - var nodePlacement *provisioner.NodePlacement - nodePlacement, err = getNodePlacement(ctx, ds.client, vd) - if err != nil { - setPhaseConditionToFailed(vd, cb, &vd.Status.Phase, fmt.Errorf("unexpected error: %w", err)) - return reconcile.Result{}, fmt.Errorf("failed to get importer tolerations: %w", err) - } - - err = ds.diskService.Start(ctx, diskSize, sc, source, vd, supgen, service.WithNodePlacement(nodePlacement)) - if updated, err := setPhaseConditionFromStorageError(err, vd, cb); err != nil || updated { - return reconcile.Result{}, err - } - - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "PVC Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vd.Status.Phase = v1alpha2.DiskPending - if dv.Status.ClaimName != "" && isStorageClassWFFC(sc) { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - } - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.QuotaExceeded, dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vd.Status.Phase = v1alpha2.DiskPending - if dv.Status.ClaimName != "" && isStorageClassWFFC(sc) { - vd.Status.Phase = v1alpha2.DiskWaitForFirstConsumer - } - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.ImagePullFailed, dvRunningCondition.Message) - ds.recorder.Event(vd, corev1.EventTypeWarning, vdcondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vd.Status.Phase = v1alpha2.DiskProvisioning - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionFalse, vdcondition.Provisioning, "PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - ds.recorder.Event( - vd, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncCompleted, - "The Upload DataSource import has completed", - ) - - vd.Status.Phase = v1alpha2.DiskReady - setReadyConditionWithWFFCAccounting(vd, cb, metav1.ConditionTrue, vdcondition.Ready, "") - - vd.Status.Progress = "100%" - vd.Status.Capacity = ds.diskService.GetCapacity(pvc) - vdsupplements.SetPVCName(vd, dv.Status.ClaimName) - - log.Info("Ready", "vd", vd.Name, "progress", vd.Status.Progress, "dv.phase", dv.Status.Phase) - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - err = ds.diskService.CheckProvisioning(ctx, pvc) - if err != nil { - return reconcile.Result{}, setPhaseConditionFromProvisioningError(ctx, err, cb, vd, dv, ds.diskService, ds.client) - } - - vd.Status.Progress = ds.diskService.GetProgress(dv, vd.Status.Progress, service.NewScaleOption(50, 100)) - vd.Status.Capacity = ds.diskService.GetCapacity(pvc) - vdsupplements.SetPVCName(vd, dv.Status.ClaimName) - - err = ds.diskService.Protect(ctx, supgen.Generator, vd, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, ptr.Deref(pvc.Spec.StorageClassName, "")) - if err != nil { - return reconcile.Result{}, err - } - - if err = setPhaseConditionForPVCProvisioningDisk(ctx, dv, vd, pvc, sc, cb, ds.diskService); err != nil { - return reconcile.Result{}, err - } - return reconcile.Result{}, nil - } - - return reconcile.Result{RequeueAfter: time.Second}, nil + return reconcile.Result{}, fmt.Errorf("fetch pvc: %w", err) + } + if pvc != nil { + ctx = logger.ToContext(ctx, log.With("pvc.name", pvc.Name, "pvc.status.phase", pvc.Status.Phase)) + } + + return steptaker.NewStepTakers[*v1alpha2.VirtualDisk]( + step.NewCleanUpUploaderStep(pod, svc, ing, ds.uploaderService), + step.NewReadyStep(ds.diskService, pvc, cb), + step.NewTerminatingStep(pvc), + step.NewCreateUploaderStep(pvc, pod, svc, ing, ds.uploaderService, ds.dvcrSettings, ds.recorder, cb), + step.NewWaitForUserUploadStep(pod, svc, ing, ds.statService, ds.uploaderService, ds.client, cb), + step.NewWaitForDVCRUploaderStep(pod, ds.statService, cb), + step.NewCreatePVCFromDVCRStep(pvc, pod, ds.statService, ds.diskService, ds.pvcService, ds.client, cb), + step.NewWaitForPVCImportStep(pvc, step.DVCRPodPVCImportSource(pod, ds.statService), ds.pvcService, ds.statService, service.NewScaleOption(50, 100), ds.client, cb), + ).Run(ctx, vd) } func (ds UploadDataSource) CleanUp(ctx context.Context, vd *v1alpha2.VirtualDisk) (bool, error) { @@ -381,37 +113,17 @@ func (ds UploadDataSource) CleanUp(ctx context.Context, vd *v1alpha2.VirtualDisk uploaderRequeue, err := ds.uploaderService.CleanUp(ctx, supgen) if err != nil { - return false, err + return false, fmt.Errorf("clean up uploader: %w", err) } diskRequeue, err := ds.diskService.CleanUp(ctx, supgen) if err != nil { - return false, err + return false, fmt.Errorf("clean up disk: %w", err) } return uploaderRequeue || diskRequeue, nil } -func (ds UploadDataSource) CleanUpSupplements(ctx context.Context, vd *v1alpha2.VirtualDisk) (reconcile.Result, error) { - supgen := vdsupplements.NewGenerator(vd) - - uploaderRequeue, err := ds.uploaderService.CleanUpSupplements(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - - diskRequeue, err := ds.diskService.CleanUpSupplements(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - - if uploaderRequeue || diskRequeue { - return reconcile.Result{RequeueAfter: time.Second}, nil - } else { - return reconcile.Result{}, nil - } -} - func (ds UploadDataSource) Validate(_ context.Context, _ *v1alpha2.VirtualDisk) error { return nil } @@ -419,43 +131,3 @@ func (ds UploadDataSource) Validate(_ context.Context, _ *v1alpha2.VirtualDisk) func (ds UploadDataSource) Name() string { return uploadDataSource } - -func (ds UploadDataSource) getEnvSettings(vd *v1alpha2.VirtualDisk, supgen supplements.Generator) *uploader.Settings { - var settings uploader.Settings - - uploader.ApplyDVCRDestinationSettings( - &settings, - ds.dvcrSettings, - supgen, - ds.dvcrSettings.RegistryImageForVD(vd), - ) - - return &settings -} - -func (ds UploadDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *cdiv1.DataVolumeSource { - // The image was preloaded from source into dvcr. - // We can't use the same data source a second time, but we can set dvcr as the data source. - // Use DV name for the Secret with DVCR auth and the ConfigMap with DVCR CA Bundle. - url := common.DockerRegistrySchemePrefix + dvcrSourceImageName - secretName := sup.DVCRAuthSecretForDV().Name - certConfigMapName := sup.DVCRCABundleConfigMapForDV().Name - - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - } -} - -func (ds UploadDataSource) getPVCSize(vd *v1alpha2.VirtualDisk, pod *corev1.Pod) (resource.Quantity, error) { - // Get size from the importer Pod to detect if specified PVC size is enough. - unpackedSize, err := resource.ParseQuantity(ds.statService.GetSize(pod).UnpackedBytes) - if err != nil { - return resource.Quantity{}, fmt.Errorf("failed to parse unpacked bytes %s: %w", ds.statService.GetSize(pod).UnpackedBytes, err) - } - - return service.GetValidatedPVCSize(vd.Spec.PersistentVolumeClaim.Size, unpackedSize) -} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/source/upload_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/source/upload_test.go new file mode 100644 index 0000000000..bdd82817c8 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/source/upload_test.go @@ -0,0 +1,456 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "errors" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/datasource" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" + "github.com/deckhouse/virtualization-controller/pkg/controller/uploader" + vdsupplements "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/supplements" + "github.com/deckhouse/virtualization-controller/pkg/dvcr" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +var _ = Describe("UploadDataSource", func() { + var ( + ctx context.Context + scheme *runtime.Scheme + vd *v1alpha2.VirtualDisk + sc *storagev1.StorageClass + pvc *corev1.PersistentVolumeClaim + disk *UploadDataSourceDiskServiceMock + pvcSvc *DataSourcePVCServiceMock + uploaderSvc *UploadDataSourceUploaderServiceMock + stat *UploadDataSourceStatServiceMock + recorder eventrecord.EventRecorderLogger + dvcrSettings *dvcr.Settings + ) + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + + scheme = runtime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(storagev1.AddToScheme(scheme)).To(Succeed()) + Expect(netv1.AddToScheme(scheme)).To(Succeed()) + + recorder = &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + + dvcrSettings = &dvcr.Settings{ + RegistryURL: "dvcr.example.com", + } + + sc = &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "sc"}, + } + + vd = &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vd", + Generation: 1, + UID: "44444444-4444-4444-4444-444444444444", + }, + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeUpload, + }, + }, + Status: v1alpha2.VirtualDiskStatus{ + StorageClassName: sc.Name, + Target: v1alpha2.DiskTarget{PersistentVolumeClaim: "test-pvc"}, + }, + } + + supgen := vdsupplements.NewGenerator(vd) + pvc = &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: supgen.PersistentVolumeClaim().Name, + Namespace: vd.Namespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: ptr.To(sc.Name)}, + Status: corev1.PersistentVolumeClaimStatus{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}, + }, + } + + disk = &UploadDataSourceDiskServiceMock{ + GetCapacityFunc: func(_ *corev1.PersistentVolumeClaim) string { return "1Gi" }, + GetPersistentVolumeClaimFunc: func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return pvc, nil + }, + CleanUpFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + CleanUpSupplementsFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + GetVolumeAndAccessModesFunc: func(_ context.Context, _ client.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil + }, + } + + pvcSvc = &DataSourcePVCServiceMock{ + FinalizersFunc: func() []string { return nil }, + ImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { + return nil + }, + WaitForImportFunc: func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + return corev1.PodRunning, nil + }, + } + + uploaderSvc = &UploadDataSourceUploaderServiceMock{ + GetPodFunc: func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return nil, nil }, + GetServiceFunc: func(_ context.Context, _ supplements.Generator) (*corev1.Service, error) { return nil, nil }, + GetIngressFunc: func(_ context.Context, _ supplements.Generator) (*netv1.Ingress, error) { return nil, nil }, + CleanUpFunc: func(_ context.Context, _ supplements.Generator) (bool, error) { return false, nil }, + GetExternalURLFunc: func(_ context.Context, _ *netv1.Ingress) string { return "https://upload.example.com" }, + GetInClusterURLFunc: func(_ context.Context, _ *corev1.Service) string { return "http://upload.svc/upload" }, + } + + stat = &UploadDataSourceStatServiceMock{ + GetDVCRImageNameFunc: func(_ *corev1.Pod) string { return "dvcr.example.com/cvi/vd:1" }, + GetSizeFunc: func(_ *corev1.Pod) v1alpha2.ImageStatusSize { + return v1alpha2.ImageStatusSize{UnpackedBytes: "500Mi"} + }, + GetFormatFunc: func(_ *corev1.Pod) string { return "qcow2" }, + GetDownloadSpeedFunc: func(_ types.UID, _ *corev1.Pod) *v1alpha2.StatusSpeed { return nil }, + GetProgressFunc: func(_ types.UID, _ *corev1.Pod, prev string, _ ...service.GetProgressOption) string { + if prev == "" { + return "10%" + } + return prev + }, + CheckPodFunc: func(_ *corev1.Pod) error { return nil }, + IsUploadStartedFunc: func(_ types.UID, _ *corev1.Pod) bool { return false }, + IsUploaderReadyFunc: func(_ *corev1.Pod, _ *corev1.Service, _ *netv1.Ingress, _ *corev1.Secret) (bool, error) { + return false, nil + }, + } + }) + + newSyncer := func(c client.Client) *UploadDataSource { + return NewUploadDataSource(recorder, stat, uploaderSvc, disk, pvcSvc, dvcrSettings, c) + } + + Context("VirtualDisk has just been created (no uploader supplements yet)", func() { + BeforeEach(func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + }) + + It("creates the uploader supplements and sets DiskProvisioning", func() { + var started bool + uploaderSvc.StartFunc = func(_ context.Context, _ *uploader.Settings, _ client.Object, _ supplements.Generator, _ *datasource.CABundle, _ ...service.Option) error { + started = true + return nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(started).To(BeTrue()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + Expect(vd.Status.Progress).To(Equal("0%")) + }) + + It("propagates QuotaExceeded as DiskFailed/QuotaExceeded", func() { + uploaderSvc.StartFunc = func(_ context.Context, _ *uploader.Settings, _ client.Object, _ supplements.Generator, _ *datasource.CABundle, _ ...service.Option) error { + return errors.New("exceeded quota: storage requested but limit reached") + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskFailed)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.QuotaExceeded, true) + }) + }) + + Context("Uploader supplements exist, user has not uploaded yet", func() { + var ( + pod *corev1.Pod + svc *corev1.Service + ing *netv1.Ingress + ) + + BeforeEach(func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + pod = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "uploader", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + svc = &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "uploader-svc", Namespace: vd.Namespace}} + ing = &netv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: "uploader-ing", Namespace: vd.Namespace}} + + uploaderSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + uploaderSvc.GetServiceFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Service, error) { return svc, nil } + uploaderSvc.GetIngressFunc = func(_ context.Context, _ supplements.Generator) (*netv1.Ingress, error) { return ing, nil } + }) + + It("reports WaitForUserUpload with ImageUploadURLs when the uploader is ready", func() { + stat.IsUploaderReadyFunc = func(_ *corev1.Pod, _ *corev1.Service, _ *netv1.Ingress, _ *corev1.Secret) (bool, error) { + return true, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskWaitForUserUpload)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.WaitForUserUpload, true) + Expect(vd.Status.ImageUploadURLs).ToNot(BeNil()) + Expect(vd.Status.ImageUploadURLs.External).ToNot(BeEmpty()) + Expect(vd.Status.ImageUploadURLs.InCluster).ToNot(BeEmpty()) + }) + + It("reports Provisioning while the uploader is not yet ready", func() { + stat.IsUploaderReadyFunc = func(_ *corev1.Pod, _ *corev1.Service, _ *netv1.Ingress, _ *corev1.Secret) (bool, error) { + return false, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + }) + + Context("User upload is in progress", func() { + BeforeEach(func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "uploader", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + uploaderSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + uploaderSvc.GetServiceFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Service, error) { + return &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "uploader-svc", Namespace: vd.Namespace}}, nil + } + uploaderSvc.GetIngressFunc = func(_ context.Context, _ supplements.Generator) (*netv1.Ingress, error) { + return &netv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: "uploader-ing", Namespace: vd.Namespace}}, nil + } + stat.IsUploadStartedFunc = func(_ types.UID, _ *corev1.Pod) bool { return true } + }) + + It("reports DiskProvisioning while uploading to DVCR", func() { + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + + It("keeps DiskProvisioning on transient uploader pod errors after upload has started", func() { + stat.CheckPodFunc = func(_ *corev1.Pod) error { return service.ErrNotScheduled } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + }) + + Context("Uploader pod has completed, no PVC yet", func() { + BeforeEach(func() { + disk.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { + return nil, nil + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "uploader", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + uploaderSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + uploaderSvc.GetServiceFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Service, error) { + return &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "uploader-svc", Namespace: vd.Namespace}}, nil + } + uploaderSvc.GetIngressFunc = func(_ context.Context, _ supplements.Generator) (*netv1.Ingress, error) { + return &netv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: "uploader-ing", Namespace: vd.Namespace}}, nil + } + }) + + It("starts the PVC import using a registry source", func() { + var started bool + pvcSvc.CreateTargetFromDVCRFunc = func(_ context.Context, _ types.NamespacedName, _ string, _ *resource.Quantity, _ client.Object, source *service.PVCImportSourceRegistry, _ service.VolumeAndAccessModesGetter, _ *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) { + started = true + Expect(source).ToNot(BeNil()) + return corev1.PersistentVolumeClaim{}, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(started).To(BeTrue()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + + It("fails the disk when the uploaded source is an ISO", func() { + stat.GetFormatFunc = func(_ *corev1.Pod) string { return "iso" } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskFailed)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.ProvisioningFailed, true) + }) + }) + + Context("PVC is Bound and the import is complete", func() { + BeforeEach(func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationDone: "true"} + }) + + It("marks DiskReady and cleans up the uploader once the condition is finished", func() { + vd.Status.Conditions = []metav1.Condition{{ + Type: vdcondition.ReadyType.String(), + Reason: vdcondition.Ready.String(), + Status: metav1.ConditionTrue, + }} + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "uploader", Namespace: vd.Namespace}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + } + uploaderSvc.GetPodFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Pod, error) { return pod, nil } + uploaderSvc.GetServiceFunc = func(_ context.Context, _ supplements.Generator) (*corev1.Service, error) { + return &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "uploader-svc", Namespace: vd.Namespace}}, nil + } + uploaderSvc.GetIngressFunc = func(_ context.Context, _ supplements.Generator) (*netv1.Ingress, error) { + return &netv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: "uploader-ing", Namespace: vd.Namespace}}, nil + } + var cleaned bool + uploaderSvc.CleanUpFunc = func(_ context.Context, _ supplements.Generator) (bool, error) { + cleaned = true + return true, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.IsZero()).To(BeTrue()) + + Expect(cleaned).To(BeTrue()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskReady)) + ExpectCondition(vd, metav1.ConditionTrue, vdcondition.Ready, false) + ExpectStats(vd) + }) + }) + + Context("Target PVC already exists and import resources were not created yet", func() { + BeforeEach(func() { + pvc.Status.Phase = corev1.ClaimBound + pvc.Annotations = map[string]string{annotations.AnnPVCPopulationStrategy: service.PopulationStrategyDVCR} + }) + + It("waits for populator to start the PVC import", func() { + var imported bool + pvcSvc.ImportFunc = func(_ context.Context, target *corev1.PersistentVolumeClaim, source *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) error { + imported = true + Expect(target.Name).To(Equal(pvc.Name)) + Expect(source).To(BeNil()) + return nil + } + pvcSvc.WaitForImportFunc = func(_ context.Context, _ *corev1.PersistentVolumeClaim, _ *service.PVCImportSource, _ client.Object, _ supplements.Generator, _ *provisioner.NodePlacement) (corev1.PodPhase, error) { + Expect(imported).To(BeTrue()) + return corev1.PodPending, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pvc).Build() + res, err := newSyncer(cl).Sync(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(res.RequeueAfter).ToNot(BeZero()) + + Expect(imported).To(BeFalse()) + Expect(vd.Status.Phase).To(Equal(v1alpha2.DiskProvisioning)) + ExpectCondition(vd, metav1.ConditionFalse, vdcondition.Provisioning, true) + }) + }) + + Context("CleanUp", func() { + It("delegates to both uploader and disk services", func() { + var uploaderCleaned, diskCleaned bool + uploaderSvc.CleanUpFunc = func(_ context.Context, _ supplements.Generator) (bool, error) { + uploaderCleaned = true + return false, nil + } + disk.CleanUpFunc = func(_ context.Context, _ supplements.Generator) (bool, error) { + diskCleaned = true + return true, nil + } + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + requeue, err := newSyncer(cl).CleanUp(ctx, vd) + Expect(err).ToNot(HaveOccurred()) + Expect(requeue).To(BeTrue()) + Expect(uploaderCleaned).To(BeTrue()) + Expect(diskCleaned).To(BeTrue()) + }) + }) + + Context("Validate", func() { + It("is a no-op", func() { + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + Expect(newSyncer(cl).Validate(ctx, vd)).To(Succeed()) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/supplements/supplements.go b/images/virtualization-artifact/pkg/controller/vd/internal/supplements/supplements.go index 4c43300f69..b9d91aead1 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/supplements/supplements.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/supplements/supplements.go @@ -42,7 +42,7 @@ func (g *VirtualDiskGenerator) SetClaimName(name string) { g.claimName = name } -func (g *VirtualDiskGenerator) DataVolume() types.NamespacedName { +func (g *VirtualDiskGenerator) CommonResourceName() types.NamespacedName { return g.PersistentVolumeClaim() } diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/validator/vdsnapshot_storage_class_provisioner_compatibility_validator.go b/images/virtualization-artifact/pkg/controller/vd/internal/validator/vdsnapshot_storage_class_provisioner_compatibility_validator.go new file mode 100644 index 0000000000..4eddc24a47 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/validator/vdsnapshot_storage_class_provisioner_compatibility_validator.go @@ -0,0 +1,106 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validator + +import ( + "context" + "fmt" + "reflect" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/virtualization-controller/pkg/common/storageclass" + commonvd "github.com/deckhouse/virtualization-controller/pkg/common/vd" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + intsvc "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +// VirtualDiskSnapshotStorageClassValidator forbids creating a VirtualDisk from a +// VirtualDiskSnapshot whose source PVC is backed by a different CSI driver than +// the target storage class. Cross-CSI-driver provisioning is not supported. +type VirtualDiskSnapshotStorageClassValidator struct { + client client.Client + scService *intsvc.VirtualDiskStorageClassService +} + +func NewVirtualDiskSnapshotStorageClassValidator(client client.Client, scService *intsvc.VirtualDiskStorageClassService) *VirtualDiskSnapshotStorageClassValidator { + return &VirtualDiskSnapshotStorageClassValidator{ + client: client, + scService: scService, + } +} + +func (v *VirtualDiskSnapshotStorageClassValidator) ValidateCreate(ctx context.Context, vd *v1alpha2.VirtualDisk) (admission.Warnings, error) { + return nil, v.validate(ctx, vd) +} + +func (v *VirtualDiskSnapshotStorageClassValidator) ValidateUpdate(ctx context.Context, oldVD, newVD *v1alpha2.VirtualDisk) (admission.Warnings, error) { + if reflect.DeepEqual(oldVD.Spec.DataSource, newVD.Spec.DataSource) { + return nil, nil + } + + ready, _ := conditions.GetCondition(vdcondition.ReadyType, newVD.Status.Conditions) + if source.IsDiskProvisioningFinished(ready) { + return nil, nil + } + + return nil, v.validate(ctx, newVD) +} + +func (v *VirtualDiskSnapshotStorageClassValidator) validate(ctx context.Context, vd *v1alpha2.VirtualDisk) error { + if vd.Spec.DataSource == nil || vd.Spec.DataSource.Type != v1alpha2.DataSourceTypeObjectRef { + return nil + } + if vd.Spec.DataSource.ObjectRef == nil || vd.Spec.DataSource.ObjectRef.Kind != v1alpha2.VirtualDiskObjectRefKindVirtualDiskSnapshot { + return nil + } + + sourceProvisioner, ok, err := storageclass.ProvisionerOfVirtualDiskSnapshot(ctx, v.client, vd.Namespace, vd.Spec.DataSource.ObjectRef.Name) + if err != nil { + return err + } + if !ok { + return nil + } + + scName, err := commonvd.ResolveStorageClassName(ctx, vd, v.scService) + if err != nil { + return err + } + + targetProvisioner, err := storageclass.ProvisionerOf(ctx, v.client, scName) + if err != nil { + return err + } + if targetProvisioner == "" { + return nil + } + + if targetProvisioner != sourceProvisioner { + return fmt.Errorf( + "virtual disk storage class %q provisioner %q does not match the source VirtualDiskSnapshot %q provisioner %q: "+ + "creating a disk from a snapshot on a different CSI driver is not supported", + scName, targetProvisioner, vd.Spec.DataSource.ObjectRef.Name, sourceProvisioner, + ) + } + + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/watcher/datavolume_watcher.go b/images/virtualization-artifact/pkg/controller/vd/internal/watcher/datavolume_watcher.go deleted file mode 100644 index 84ce2c9082..0000000000 --- a/images/virtualization-artifact/pkg/controller/vd/internal/watcher/datavolume_watcher.go +++ /dev/null @@ -1,93 +0,0 @@ -/* -Copyright 2025 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package watcher - -import ( - "fmt" - - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/event" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/source" - - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization/api/core/v1alpha2" -) - -type DataVolumeWatcher struct{} - -func NewDataVolumeWatcher() *DataVolumeWatcher { - return &DataVolumeWatcher{} -} - -func (w *DataVolumeWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { - if err := ctr.Watch( - source.Kind(mgr.GetCache(), &cdiv1.DataVolume{}, - handler.TypedEnqueueRequestForOwner[*cdiv1.DataVolume]( - mgr.GetScheme(), - mgr.GetRESTMapper(), - &v1alpha2.VirtualDisk{}, - handler.OnlyControllerOwner(), - ), - predicate.TypedFuncs[*cdiv1.DataVolume]{ - CreateFunc: func(e event.TypedCreateEvent[*cdiv1.DataVolume]) bool { return false }, - UpdateFunc: func(e event.TypedUpdateEvent[*cdiv1.DataVolume]) bool { - if e.ObjectOld.Status.Progress != e.ObjectNew.Status.Progress { - return true - } - - if e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase { - switch e.ObjectNew.Status.Phase { - case cdiv1.Succeeded, cdiv1.WaitForFirstConsumer, cdiv1.PendingPopulation: - return true - } - } - - if e.ObjectOld.Status.ClaimName != e.ObjectNew.Status.ClaimName { - return true - } - - oldDVQuotaNotExceeded, oldOk := conditions.GetDataVolumeCondition(conditions.DVQoutaNotExceededConditionType, e.ObjectOld.Status.Conditions) - newDVQuotaNotExceeded, newOk := conditions.GetDataVolumeCondition(conditions.DVQoutaNotExceededConditionType, e.ObjectNew.Status.Conditions) - - if !oldOk && newOk { - return true - } - - if oldOk && newOk && oldDVQuotaNotExceeded != newDVQuotaNotExceeded { - return true - } - - oldDVRunning, _ := conditions.GetDataVolumeCondition(conditions.DVRunningConditionType, e.ObjectOld.Status.Conditions) - newDVRunning, _ := conditions.GetDataVolumeCondition(conditions.DVRunningConditionType, e.ObjectNew.Status.Conditions) - - if oldDVRunning.Reason != newDVRunning.Reason { - return true - } - - return newDVRunning.Reason == "Error" || newDVRunning.Reason == "ImagePullFailed" - }, - }, - ), - ); err != nil { - return fmt.Errorf("error setting watch on DV: %w", err) - } - return nil -} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/watcher/pod_watcher.go b/images/virtualization-artifact/pkg/controller/vd/internal/watcher/pod_watcher.go new file mode 100644 index 0000000000..6e41f5d9b9 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/watcher/pod_watcher.go @@ -0,0 +1,100 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "context" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type PodWatcher struct { + logger *log.Logger + client client.Client +} + +func NewPodWatcher(client client.Client) *PodWatcher { + return &PodWatcher{ + logger: log.Default().With("watcher", strings.ToLower("Pod")), + client: client, + } +} + +func (w PodWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + if err := ctr.Watch( + source.Kind(mgr.GetCache(), &corev1.Pod{}, + handler.TypedEnqueueRequestsFromMapFunc(w.enqueueRequestsFromPVC), + predicate.TypedFuncs[*corev1.Pod]{ + UpdateFunc: func(e event.TypedUpdateEvent[*corev1.Pod]) bool { + return e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase + }, + CreateFunc: func(e event.TypedCreateEvent[*corev1.Pod]) bool { return false }, + DeleteFunc: func(e event.TypedDeleteEvent[*corev1.Pod]) bool { return false }, + }, + ), + ); err != nil { + return fmt.Errorf("error setting watch on Pod: %w", err) + } + return nil +} + +func (w PodWatcher) enqueueRequestsFromPVC(ctx context.Context, pod *corev1.Pod) []reconcile.Request { + for _, ownerRef := range pod.OwnerReferences { + if ownerRef.Kind == v1alpha2.VirtualDiskKind { + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{Name: ownerRef.Name, Namespace: pod.Namespace}, + }} + } + + if ownerRef.Kind != "PersistentVolumeClaim" { + continue + } + + target, err := object.FetchObject(ctx, types.NamespacedName{Name: ownerRef.Name, Namespace: pod.Namespace}, w.client, &corev1.PersistentVolumeClaim{}) + if err != nil { + w.logger.Error(fmt.Sprintf("failed to fetch pod owner pvc: %s", err)) + continue + } + if target == nil { + continue + } + + for _, pvcOwnerRef := range target.OwnerReferences { + if pvcOwnerRef.Kind == v1alpha2.VirtualDiskKind { + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{Name: pvcOwnerRef.Name, Namespace: target.Namespace}, + }} + } + } + } + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/watcher/pvc_watcher.go b/images/virtualization-artifact/pkg/controller/vd/internal/watcher/pvc_watcher.go index c771577ce1..96e185bc1f 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/watcher/pvc_watcher.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/watcher/pvc_watcher.go @@ -24,7 +24,6 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/types" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/event" @@ -35,8 +34,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/virtualization-controller/pkg/common/datavolume" - "github.com/deckhouse/virtualization-controller/pkg/common/object" "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -69,31 +66,15 @@ func (w PersistentVolumeClaimWatcher) Watch(mgr manager.Manager, ctr controller. return nil } -func (w PersistentVolumeClaimWatcher) enqueueRequestsFromOwnerRefsRecursively(ctx context.Context, obj client.Object) (requests []reconcile.Request) { +func (w PersistentVolumeClaimWatcher) enqueueRequestsFromOwnerRefsRecursively(_ context.Context, obj client.Object) (requests []reconcile.Request) { for _, ownerRef := range obj.GetOwnerReferences() { - switch ownerRef.Kind { - case v1alpha2.VirtualDiskKind: + if ownerRef.Kind == v1alpha2.VirtualDiskKind { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ Name: ownerRef.Name, Namespace: obj.GetNamespace(), }, }) - case datavolume.DataVolumeKind: - dv, err := object.FetchObject(ctx, types.NamespacedName{ - Name: ownerRef.Name, - Namespace: obj.GetNamespace(), - }, w.client, &cdiv1.DataVolume{}) - if err != nil { - w.logger.Error(fmt.Sprintf("failed to fetch dv: %s", err)) - continue - } - - if dv == nil { - continue - } - - requests = append(requests, w.enqueueRequestsFromOwnerRefsRecursively(ctx, dv)...) } } diff --git a/images/virtualization-artifact/pkg/controller/vd/vd_controller.go b/images/virtualization-artifact/pkg/controller/vd/vd_controller.go index 5eae311fdf..0d46e572d1 100644 --- a/images/virtualization-artifact/pkg/controller/vd/vd_controller.go +++ b/images/virtualization-artifact/pkg/controller/vd/vd_controller.go @@ -58,6 +58,7 @@ func NewController( mgr manager.Manager, log *log.Logger, importerImage string, + diskImporterImage string, uploaderImage string, requirements corev1.ResourceRequirements, dvcr *dvcr.Settings, @@ -67,18 +68,23 @@ func NewController( protection := service.NewProtectionService(mgr.GetClient(), v1alpha2.FinalizerVDProtection) importer := service.NewImporterService(dvcr, mgr.GetClient(), importerImage, requirements, PodPullPolicy, PodVerbose, ControllerName, protection) uploader := service.NewUploaderService(dvcr, mgr.GetClient(), uploaderImage, requirements, PodPullPolicy, PodVerbose, ControllerName, protection) - disk := service.NewDiskService(mgr.GetClient(), dvcr, protection, ControllerName) + disk := service.NewDiskService(mgr.GetClient(), dvcr, protection, ControllerName, service.DiskImporterConfig{ + Image: diskImporterImage, + ResourceRequirements: requirements, + PullPolicy: PodPullPolicy, + Verbose: PodVerbose, + }) scService := intsvc.NewVirtualDiskStorageClassService(service.NewBaseStorageClassService(mgr.GetClient()), storageClassSettings) dvcrService := service.NewDVCRService(mgr.GetClient()) recorder := eventrecord.NewEventRecorderLogger(mgr, ControllerName) - blank := source.NewBlankDataSource(recorder, disk, mgr.GetClient()) - sources := source.NewSources() - sources.Set(v1alpha2.DataSourceTypeHTTP, source.NewHTTPDataSource(recorder, stat, importer, disk, dvcr, mgr.GetClient())) - sources.Set(v1alpha2.DataSourceTypeContainerImage, source.NewRegistryDataSource(recorder, stat, importer, disk, dvcr, mgr.GetClient())) - sources.Set(v1alpha2.DataSourceTypeObjectRef, source.NewObjectRefDataSource(recorder, disk, mgr.GetClient())) - sources.Set(v1alpha2.DataSourceTypeUpload, source.NewUploadDataSource(recorder, stat, uploader, disk, dvcr, mgr.GetClient())) + pvcSvc := disk.PersistentVolumeClaim() + blank := source.NewBlankDataSource(recorder, disk, pvcSvc, mgr.GetClient()) + sources.Set(v1alpha2.DataSourceTypeHTTP, source.NewHTTPDataSource(recorder, stat, importer, disk, pvcSvc, dvcr, mgr.GetClient())) + sources.Set(v1alpha2.DataSourceTypeContainerImage, source.NewRegistryDataSource(recorder, stat, importer, disk, pvcSvc, dvcr, mgr.GetClient())) + sources.Set(v1alpha2.DataSourceTypeObjectRef, source.NewObjectRefDataSource(recorder, stat, disk, mgr.GetClient())) + sources.Set(v1alpha2.DataSourceTypeUpload, source.NewUploadDataSource(recorder, stat, uploader, disk, pvcSvc, dvcr, mgr.GetClient())) reconciler := NewReconciler( mgr.GetClient(), diff --git a/images/virtualization-artifact/pkg/controller/vd/vd_reconciler.go b/images/virtualization-artifact/pkg/controller/vd/vd_reconciler.go index 3a36204b6c..e218bca711 100644 --- a/images/virtualization-artifact/pkg/controller/vd/vd_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/vd/vd_reconciler.go @@ -103,9 +103,9 @@ func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr for _, w := range []Watcher{ watcher.NewVirtualDiskWatcher(), watcher.NewPersistentVolumeClaimWatcher(mgrClient), + watcher.NewPodWatcher(mgrClient), watcher.NewVirtualDiskSnapshotWatcher(mgrClient), watcher.NewStorageClassWatcher(mgrClient), - watcher.NewDataVolumeWatcher(), watcher.NewVirtualMachineWatcher(), watcher.NewResourceQuotaWatcher(mgrClient), postponeimporter.NewWatcher[*v1alpha2.VirtualDisk](mgrClient, logger), diff --git a/images/virtualization-artifact/pkg/controller/vd/vd_webhook.go b/images/virtualization-artifact/pkg/controller/vd/vd_webhook.go index a86a3a4f3f..28bc2f7147 100644 --- a/images/virtualization-artifact/pkg/controller/vd/vd_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vd/vd_webhook.go @@ -50,6 +50,7 @@ func NewValidator(client client.Client, scService *intsvc.VirtualDiskStorageClas validator.NewNameValidator(), validator.NewMigrationStorageClassValidator(client, scService, modeGetter, featuregates.Default()), validator.NewVirtualImagePVCStorageClassValidator(client, scService), + validator.NewVirtualDiskSnapshotStorageClassValidator(client, scService), }, } } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/life_cycle.go b/images/virtualization-artifact/pkg/controller/vi/internal/life_cycle.go index 7e443b0c57..a12390d789 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/life_cycle.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/life_cycle.go @@ -25,6 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization-controller/pkg/common" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/vi/internal/source" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -128,14 +129,62 @@ func (h LifeCycleHandler) Handle(ctx context.Context, vi *v1alpha2.VirtualImage) return reconcile.Result{}, fmt.Errorf("data source runner not found for type: %s", vi.Spec.DataSource.Type) } + var ( + result reconcile.Result + err error + ) switch vi.Spec.Storage { case v1alpha2.StorageKubernetes, v1alpha2.StoragePersistentVolumeClaim: - return ds.StoreToPVC(ctx, vi) + result, err = ds.StoreToPVC(ctx, vi) case v1alpha2.StorageContainerRegistry: - return ds.StoreToDVCR(ctx, vi) + result, err = ds.StoreToDVCR(ctx, vi) default: return reconcile.Result{}, fmt.Errorf("unknown spec storage: %s", vi.Spec.Storage) } + if err != nil { + // The namespace is being torn down (e.g. Project/namespace cleanup): the + // VirtualImage is going away too, so creating helper objects is legitimately + // rejected. Surface it on the Ready condition instead of failing the reconcile. + if common.ErrNamespaceTerminating(err) { + cb. + Status(metav1.ConditionFalse). + Reason(vicondition.Provisioning). + Message("Namespace is terminating: provisioning is paused.") + conditions.SetCondition(cb, &vi.Status.Conditions) + return reconcile.Result{}, nil + } + return result, err + } + + normalizeProgress(vi) + + return result, nil +} + +// normalizeProgress enforces the phase/progress invariants on the final status: +// - an image that has not yet entered Provisioning ("" or Pending) must NOT +// expose any progress percentage. Progress describes how far the import +// has advanced, so it is meaningful only once the import has actually +// started; any earlier setter (some source paths optimistically populate +// "0%" before they have decided whether the phase will advance to +// Provisioning in this reconcile) is unconditionally cleared here so that +// consumers never observe e.g. "Pending 0%"; +// - an image in the Provisioning phase must always expose a progress +// percentage; until the importer reports real progress it defaults to +// "0%"; +// - an image parked in WaitForUserUpload has not received any data yet, so +// its progress is always "0%". +func normalizeProgress(vi *v1alpha2.VirtualImage) { + switch vi.Status.Phase { + case "", v1alpha2.ImagePending: + vi.Status.Progress = "" + case v1alpha2.ImageProvisioning: + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } + case v1alpha2.ImageWaitForUserUpload: + vi.Status.Progress = "0%" + } } func (h LifeCycleHandler) Name() string { diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/life_cycle_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/life_cycle_test.go index f6d185dd23..a790cc2597 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/life_cycle_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/life_cycle_test.go @@ -18,6 +18,7 @@ package internal import ( "context" + "errors" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -25,6 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/vi/internal/source" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -221,6 +223,90 @@ var _ = Describe("LifeCycleHandler Run", func() { }, ), ) + + DescribeTable( + "Phase/progress invariants on the final status", + func(phase v1alpha2.ImagePhase, syncProgress, expectedProgress string) { + var sourcesMock SourcesMock + sourcesMock.ChangedFunc = func(_ context.Context, _ *v1alpha2.VirtualImage) bool { + return false + } + sourcesMock.ForFunc = func(_ v1alpha2.DataSourceType) (source.Handler, bool) { + return &source.HandlerMock{StoreToDVCRFunc: func(_ context.Context, vi *v1alpha2.VirtualImage) (reconcile.Result, error) { + vi.Status.Phase = phase + vi.Status.Progress = syncProgress + return reconcile.Result{}, nil + }}, true + } + recorder := &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + vi := v1alpha2.VirtualImage{ + Spec: v1alpha2.VirtualImageSpec{ + Storage: v1alpha2.StorageContainerRegistry, + }, + Status: v1alpha2.VirtualImageStatus{ + Conditions: []metav1.Condition{ + { + Type: vicondition.DatasourceReadyType.String(), + Status: metav1.ConditionTrue, + }, + }, + }, + } + + handler := NewLifeCycleHandler(recorder, &sourcesMock, nil) + _, err := handler.Handle(context.TODO(), &vi) + Expect(err).NotTo(HaveOccurred()) + Expect(vi.Status.Phase).To(Equal(phase)) + Expect(vi.Status.Progress).To(Equal(expectedProgress)) + }, + Entry("Provisioning defaults empty progress to 0%", v1alpha2.ImageProvisioning, "", "0%"), + Entry("Provisioning keeps the reported progress", v1alpha2.ImageProvisioning, "42.0%", "42.0%"), + Entry("WaitForUserUpload forces empty progress to 0%", v1alpha2.ImageWaitForUserUpload, "", "0%"), + Entry("WaitForUserUpload forces progress to 0%", v1alpha2.ImageWaitForUserUpload, "73%", "0%"), + Entry("Pending clears any progress prematurely set by a source path", v1alpha2.ImagePending, "55%", ""), + Entry("Pending keeps progress empty", v1alpha2.ImagePending, "", ""), + Entry("empty phase clears any progress prematurely set by a source path", v1alpha2.ImagePhase(""), "10%", ""), + Entry("Ready keeps the reported progress untouched", v1alpha2.ImageReady, "100%", "100%"), + ) + + It("surfaces a namespace-terminating store error on the Ready condition without failing the reconcile", func() { + var sourcesMock SourcesMock + sourcesMock.ChangedFunc = func(_ context.Context, _ *v1alpha2.VirtualImage) bool { + return false + } + sourcesMock.ForFunc = func(_ v1alpha2.DataSourceType) (source.Handler, bool) { + return &source.HandlerMock{StoreToDVCRFunc: func(_ context.Context, _ *v1alpha2.VirtualImage) (reconcile.Result, error) { + return reconcile.Result{}, errors.New(`secrets "d8v-vi-dvcr-auth" is forbidden: unable to create new content in namespace ns because it is being terminated`) + }}, true + } + recorder := &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + vi := v1alpha2.VirtualImage{ + Spec: v1alpha2.VirtualImageSpec{ + Storage: v1alpha2.StorageContainerRegistry, + }, + Status: v1alpha2.VirtualImageStatus{ + Conditions: []metav1.Condition{ + { + Type: vicondition.DatasourceReadyType.String(), + Status: metav1.ConditionTrue, + }, + }, + }, + } + + handler := NewLifeCycleHandler(recorder, &sourcesMock, nil) + _, err := handler.Handle(context.TODO(), &vi) + Expect(err).NotTo(HaveOccurred()) + + readyCond, ok := conditions.GetCondition(vicondition.ReadyType, vi.Status.Conditions) + Expect(ok).To(BeTrue()) + Expect(readyCond.Status).To(Equal(metav1.ConditionFalse)) + Expect(readyCond.Reason).To(Equal(vicondition.Provisioning.String())) + }) }) type cleanupAfterSpecChangeTestArgs struct { diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/service/vi_storage_class_service.go b/images/virtualization-artifact/pkg/controller/vi/internal/service/vi_storage_class_service.go index 854e1eedc1..446efcc367 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/service/vi_storage_class_service.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/service/vi_storage_class_service.go @@ -127,13 +127,15 @@ func (svc *VirtualImageStorageClassService) ValidateClaimPropertySets(sp *cdiv1. } for _, cps := range sp.Status.ClaimPropertySets { - if slices.Contains(cps.AccessModes, corev1.ReadWriteMany) && *cps.VolumeMode == corev1.PersistentVolumeBlock { + if (slices.Contains(cps.AccessModes, corev1.ReadWriteOnce) || slices.Contains(cps.AccessModes, corev1.ReadWriteMany)) && + cps.VolumeMode != nil && + (*cps.VolumeMode == corev1.PersistentVolumeBlock || *cps.VolumeMode == corev1.PersistentVolumeFilesystem) { return nil } } return fmt.Errorf( - "the storage class %q lacks of capabilities to support 'Virtual Images on PVC' function; use StorageClass that supports volume mode 'Block' and access mode 'ReadWriteMany'", + "the storage class %q lacks of capabilities to support 'Virtual Images on PVC' function; use StorageClass that supports volume mode 'Block' or 'Filesystem' and access mode 'ReadWriteOnce' or 'ReadWriteMany'", sp.Name, ) } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/service/vi_storage_class_service_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/service/vi_storage_class_service_test.go index a4146734f7..f2650eb368 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/service/vi_storage_class_service_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/service/vi_storage_class_service_test.go @@ -206,7 +206,7 @@ var _ = Describe("VirtualImageStorageClassService", func() { service = NewVirtualImageStorageClassService(nil, config.VirtualImageStorageClassSettings{}) }) When("a storage profile has the volume mode `Filesystem` and the access mode `ReadWriteMany`", func() { - It("returns an error", func() { + It("does not return an error", func() { sp := &cdiv1.StorageProfile{ ObjectMeta: metav1.ObjectMeta{ Name: "FilesystemStorageClass", @@ -222,6 +222,26 @@ var _ = Describe("VirtualImageStorageClassService", func() { }, } err := service.ValidateClaimPropertySets(sp) + Expect(err).NotTo(HaveOccurred()) + }) + }) + When("a storage profile has the volume mode `Filesystem` and the access mode `ReadOnlyMany`", func() { + It("returns an error", func() { + sp := &cdiv1.StorageProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ReadOnlyFilesystemStorageClass", + }, + Spec: cdiv1.StorageProfileSpec{}, + Status: cdiv1.StorageProfileStatus{ + ClaimPropertySets: []cdiv1.ClaimPropertySet{ + { + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadOnlyMany}, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + }, + }, + }, + } + err := service.ValidateClaimPropertySets(sp) Expect(err).To(HaveOccurred()) }) }) @@ -254,7 +274,7 @@ var _ = Describe("VirtualImageStorageClassService", func() { }) }) When("a storage profile has the volume mode `Block` and the access mode `ReadWriteOnce`", func() { - It("returns an error", func() { + It("does not return an error", func() { sp := &cdiv1.StorageProfile{ ObjectMeta: metav1.ObjectMeta{ Name: "BlockStorageClass", @@ -274,7 +294,7 @@ var _ = Describe("VirtualImageStorageClassService", func() { }, } err := service.ValidateClaimPropertySets(sp) - Expect(err).To(HaveOccurred()) + Expect(err).NotTo(HaveOccurred()) }) }) }) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/http.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/http.go index fb5e3915a5..21fa7bcb43 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/http.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/http.go @@ -23,10 +23,8 @@ import ( "time" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common" @@ -101,10 +99,13 @@ func (ds HTTPDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.VirtualIm return CleanUpSupplements(ctx, vi, ds) case object.IsTerminating(pod): vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Progress = "" log.Info("Cleaning up...") case pod == nil: - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } envSettings := ds.getEnvSettings(vi, supgen) @@ -156,7 +157,7 @@ func (ds HTTPDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.VirtualIm vi.Status.Size = ds.statService.GetSize(pod) vi.Status.CDROM = ds.statService.GetCDROM(pod) vi.Status.Format = ds.statService.GetFormat(pod) - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + vi.Status.Progress = "100%" vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) @@ -178,7 +179,7 @@ func (ds HTTPDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.VirtualIm Message("Import is in the process of provisioning to DVCR.") vi.Status.Phase = v1alpha2.ImageProvisioning - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress), 100) vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) @@ -200,51 +201,35 @@ func (ds HTTPDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.VirtualIma if err != nil { return reconcile.Result{}, err } - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { return reconcile.Result{}, err } - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - } - switch { case IsImageProvisioningFinished(condition): log.Info("Image provisioning finished: clean up") setPhaseConditionForFinishedImage(pvc, cb, &vi.Status.Phase, supgen) - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen, vi, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - // Unprotect import time supplements to delete them later. err = ds.importerService.Unprotect(ctx, pod, supgen) if err != nil { return reconcile.Result{}, err } - err = ds.diskService.Unprotect(ctx, supgen, dv) + err = ds.diskService.Unprotect(ctx, supgen) if err != nil { return reconcile.Result{}, err } return CleanUpSupplements(ctx, vi, ds) - case object.AnyTerminating(pod, dv, pvc): + case object.AnyTerminating(pod, pvc): log.Info("Waiting for supplements to be terminated") case pod == nil: - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } envSettings := ds.getEnvSettings(vi, supgen) err = ds.importerService.Start(ctx, envSettings, vi, supgen, datasource.NewCABundleForVMI(vi.GetNamespace(), vi.Spec.DataSource), service.WithSystemNodeToleration()) @@ -287,124 +272,17 @@ func (ds HTTPDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.VirtualIma Reason(vicondition.Provisioning). Message("Import is in the process of provisioning to DVCR.") - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress, service.NewScaleOption(0, 50)) + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress, service.NewScaleOption(0, 50)), 100) vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) - case dv == nil: + default: ds.recorder.Event( vi, corev1.EventTypeNormal, v1alpha2.ReasonDataSourceSyncStarted, "The HTTP DataSource import has started", ) - - err = ds.statService.CheckPod(pod) - if err != nil { - vi.Status.Phase = v1alpha2.ImageFailed - - switch { - case errors.Is(err, service.ErrProvisioningFailed): - ds.recorder.Event(vi, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceDiskProvisioningFailed, "Disk provisioning failed") - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ProvisioningFailed). - Message(service.CapitalizeFirstLetter(err.Error() + ".")) - return reconcile.Result{}, nil - default: - return reconcile.Result{}, err - } - } - - vi.Status.Progress = "50.0%" - vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) - - var diskSize resource.Quantity - diskSize, err = ds.getPVCSize(pod) - if err != nil { - setPhaseConditionToFailed(cb, &vi.Status.Phase, err) - - if errors.Is(err, service.ErrInsufficientPVCSize) { - return reconcile.Result{}, nil - } - - return reconcile.Result{}, err - } - source := ds.getSource(supgen, ds.statService.GetDVCRImageName(pod)) - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vi.Status.StorageClassName) - if err != nil { - return reconcile.Result{}, err - } - err = ds.diskService.StartImmediate(ctx, diskSize, sc, source, vi, supgen) - if updated, err := setPhaseConditionFromStorageError(err, vi, cb); err != nil || updated { - return reconcile.Result{}, err - } - - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("DVCR Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.QuotaExceeded). - Message(dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ImagePullFailed). - Message(dvRunningCondition.Message) - ds.recorder.Event(vi, corev1.EventTypeWarning, vicondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - ds.recorder.Event( - vi, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncCompleted, - "The HTTP DataSource import has completed", - ) - - vi.Status.Phase = v1alpha2.ImageReady - cb. - Status(metav1.ConditionTrue). - Reason(vicondition.Ready). - Message("") - - vi.Status.Progress = "100%" - vi.Status.Size = ds.statService.GetSize(pod) - vi.Status.CDROM = ds.statService.GetCDROM(pod) - vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - vi.Status.Progress = ds.diskService.GetProgress(dv, vi.Status.Progress, service.NewScaleOption(50, 100)) - - err = ds.diskService.Protect(ctx, supgen, vi, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } - - err = setPhaseConditionForPVCProvisioningImage(ctx, dv, vi, pvc, cb, ds.diskService) - if err != nil { - return reconcile.Result{}, err - } - - return reconcile.Result{}, nil + return reconcilePVCImportFromDVCR(ctx, vi, pod, pvc, source, cb, supgen, ds.statService, ds.diskService) } return reconcile.Result{RequeueAfter: time.Second}, nil @@ -475,10 +353,10 @@ func (ds HTTPDataSource) getPVCSize(pod *corev1.Pod) (resource.Quantity, error) return resource.Quantity{}, errors.New("got zero unpacked size from data source") } - return service.GetValidatedPVCSize(&unpackedSize, unpackedSize) + return service.GetValidatedPVCSize(nil, unpackedSize) } -func (ds HTTPDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *cdiv1.DataVolumeSource { +func (ds HTTPDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *service.PVCImportSource { // The image was preloaded from source into dvcr. // We can't use the same data source a second time, but we can set dvcr as the data source. // Use DV name for the Secret with DVCR auth and the ConfigMap with DVCR CA Bundle. @@ -486,11 +364,5 @@ func (ds HTTPDataSource) getSource(sup supplements.Generator, dvcrSourceImageNam secretName := sup.DVCRAuthSecretForDV().Name certConfigMapName := sup.DVCRCABundleConfigMapForDV().Name - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - } + return service.NewPVCRegistryImportSource(url, secretName, certConfigMapName) } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/interfaces.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/interfaces.go index 15c88d7335..f1dee4d1cf 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/interfaces.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/interfaces.go @@ -75,6 +75,8 @@ type Bounder interface { } type Disk interface { + service.VolumeAndAccessModesGetter GetPersistentVolumeClaim(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) + PersistentVolumeClaim() *service.PersistentVolumeClaimService CleanUpSupplements(ctx context.Context, sup supplements.Generator) (bool, error) } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/mock.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/mock.go index a21a10c351..56afc7344f 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/mock.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/mock.go @@ -13,6 +13,7 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" corev1 "k8s.io/api/core/v1" netv1 "k8s.io/api/networking/v1" + storagev1 "k8s.io/api/storage/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -1984,6 +1985,12 @@ var _ Disk = &DiskMock{} // GetPersistentVolumeClaimFunc: func(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) { // panic("mock out the GetPersistentVolumeClaim method") // }, +// GetVolumeAndAccessModesFunc: func(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { +// panic("mock out the GetVolumeAndAccessModes method") +// }, +// PersistentVolumeClaimFunc: func() *service.PersistentVolumeClaimService { +// panic("mock out the PersistentVolumeClaim method") +// }, // } // // // use mockedDisk in code that requires Disk @@ -1997,6 +2004,12 @@ type DiskMock struct { // GetPersistentVolumeClaimFunc mocks the GetPersistentVolumeClaim method. GetPersistentVolumeClaimFunc func(ctx context.Context, sup supplements.Generator) (*corev1.PersistentVolumeClaim, error) + // GetVolumeAndAccessModesFunc mocks the GetVolumeAndAccessModes method. + GetVolumeAndAccessModesFunc func(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) + + // PersistentVolumeClaimFunc mocks the PersistentVolumeClaim method. + PersistentVolumeClaimFunc func() *service.PersistentVolumeClaimService + // calls tracks calls to the methods. calls struct { // CleanUpSupplements holds details about calls to the CleanUpSupplements method. @@ -2013,9 +2026,23 @@ type DiskMock struct { // Sup is the sup argument value. Sup supplements.Generator } + // GetVolumeAndAccessModes holds details about calls to the GetVolumeAndAccessModes method. + GetVolumeAndAccessModes []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Obj is the obj argument value. + Obj client.Object + // Sc is the sc argument value. + Sc *storagev1.StorageClass + } + // PersistentVolumeClaim holds details about calls to the PersistentVolumeClaim method. + PersistentVolumeClaim []struct { + } } lockCleanUpSupplements sync.RWMutex lockGetPersistentVolumeClaim sync.RWMutex + lockGetVolumeAndAccessModes sync.RWMutex + lockPersistentVolumeClaim sync.RWMutex } // CleanUpSupplements calls CleanUpSupplementsFunc. @@ -2089,3 +2116,70 @@ func (mock *DiskMock) GetPersistentVolumeClaimCalls() []struct { mock.lockGetPersistentVolumeClaim.RUnlock() return calls } + +// GetVolumeAndAccessModes calls GetVolumeAndAccessModesFunc. +func (mock *DiskMock) GetVolumeAndAccessModes(ctx context.Context, obj client.Object, sc *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + if mock.GetVolumeAndAccessModesFunc == nil { + panic("DiskMock.GetVolumeAndAccessModesFunc: method is nil but Disk.GetVolumeAndAccessModes was just called") + } + callInfo := struct { + Ctx context.Context + Obj client.Object + Sc *storagev1.StorageClass + }{ + Ctx: ctx, + Obj: obj, + Sc: sc, + } + mock.lockGetVolumeAndAccessModes.Lock() + mock.calls.GetVolumeAndAccessModes = append(mock.calls.GetVolumeAndAccessModes, callInfo) + mock.lockGetVolumeAndAccessModes.Unlock() + return mock.GetVolumeAndAccessModesFunc(ctx, obj, sc) +} + +// GetVolumeAndAccessModesCalls gets all the calls that were made to GetVolumeAndAccessModes. +// Check the length with: +// +// len(mockedDisk.GetVolumeAndAccessModesCalls()) +func (mock *DiskMock) GetVolumeAndAccessModesCalls() []struct { + Ctx context.Context + Obj client.Object + Sc *storagev1.StorageClass +} { + var calls []struct { + Ctx context.Context + Obj client.Object + Sc *storagev1.StorageClass + } + mock.lockGetVolumeAndAccessModes.RLock() + calls = mock.calls.GetVolumeAndAccessModes + mock.lockGetVolumeAndAccessModes.RUnlock() + return calls +} + +// PersistentVolumeClaim calls PersistentVolumeClaimFunc. +func (mock *DiskMock) PersistentVolumeClaim() *service.PersistentVolumeClaimService { + if mock.PersistentVolumeClaimFunc == nil { + panic("DiskMock.PersistentVolumeClaimFunc: method is nil but Disk.PersistentVolumeClaim was just called") + } + callInfo := struct { + }{} + mock.lockPersistentVolumeClaim.Lock() + mock.calls.PersistentVolumeClaim = append(mock.calls.PersistentVolumeClaim, callInfo) + mock.lockPersistentVolumeClaim.Unlock() + return mock.PersistentVolumeClaimFunc() +} + +// PersistentVolumeClaimCalls gets all the calls that were made to PersistentVolumeClaim. +// Check the length with: +// +// len(mockedDisk.PersistentVolumeClaimCalls()) +func (mock *DiskMock) PersistentVolumeClaimCalls() []struct { +} { + var calls []struct { + } + mock.lockPersistentVolumeClaim.RLock() + calls = mock.calls.PersistentVolumeClaim + mock.lockPersistentVolumeClaim.RUnlock() + return calls +} diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref.go index ed1b52f60f..e82aec37e9 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref.go @@ -23,12 +23,10 @@ import ( "time" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -83,8 +81,8 @@ func NewObjectRefDataSource( client: client, diskService: diskService, recorder: recorder, - viObjectRefOnPvc: NewObjectRefDataVirtualImageOnPVC(recorder, statService, importerService, dvcrSettings, client, diskService), - vdSyncer: NewObjectRefVirtualDisk(recorder, importerService, client, diskService, dvcrSettings, statService), + viObjectRefOnPvc: NewObjectRefDataVirtualImageOnPVC(recorder, statService, importerService, bounderService, dvcrSettings, client, diskService), + vdSyncer: NewObjectRefVirtualDisk(recorder, importerService, bounderService, client, diskService, dvcrSettings, statService), vdSnapshotCRSyncer: NewObjectRefVirtualDiskSnapshotCR(importerService, statService, diskService, client, dvcrSettings, recorder), vdSnapshotPVCSyncer: NewObjectRefVirtualDiskSnapshotPVC(importerService, statService, bounderService, diskService, client, dvcrSettings, recorder), } @@ -131,20 +129,15 @@ func (ds ObjectRefDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.Virtu } supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { return reconcile.Result{}, err } - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) + var dvcrDataSource controller.DVCRDataSource + dvcrDataSource, err = controller.NewDVCRDataSourcesForVMI(ctx, vi.Spec.DataSource, vi, ds.client) + if err != nil { + return reconcile.Result{}, err } switch { @@ -153,21 +146,15 @@ func (ds ObjectRefDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.Virtu setPhaseConditionForFinishedImage(pvc, cb, &vi.Status.Phase, supgen) - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen, vi, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - - err = ds.diskService.Unprotect(ctx, supgen, dv) + err = ds.diskService.Unprotect(ctx, supgen) if err != nil { return reconcile.Result{}, err } return CleanUpSupplements(ctx, vi, ds) - case object.AnyTerminating(dv, pvc): + case object.AnyTerminating(pvc): log.Info("Waiting for supplements to be terminated") - case dv == nil: + default: ds.recorder.Event( vi, corev1.EventTypeNormal, @@ -175,12 +162,6 @@ func (ds ObjectRefDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.Virtu "The ObjectRef DataSource import has started", ) - var dvcrDataSource controller.DVCRDataSource - dvcrDataSource, err = controller.NewDVCRDataSourcesForVMI(ctx, vi.Spec.DataSource, vi, ds.client) - if err != nil { - return reconcile.Result{}, err - } - if !dvcrDataSource.IsReady() { cb. Status(metav1.ConditionFalse). @@ -189,7 +170,9 @@ func (ds ObjectRefDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.Virtu return reconcile.Result{}, nil } - vi.Status.Progress = "0%" + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } vi.Status.SourceUID = ptr.To(dvcrDataSource.GetUID()) var diskSize resource.Quantity @@ -204,94 +187,17 @@ func (ds ObjectRefDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.Virtu return reconcile.Result{}, err } - var source *cdiv1.DataVolumeSource + var source *service.PVCImportSource source, err = ds.getSource(supgen, dvcrDataSource) if err != nil { return reconcile.Result{}, err } - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vi.Status.StorageClassName) - if err != nil { - return reconcile.Result{}, err - } - err = ds.diskService.StartImmediate(ctx, diskSize, sc, source, vi, supgen) - if updated, err := setPhaseConditionFromStorageError(err, vi, cb); err != nil || updated { - return reconcile.Result{}, err - } - - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.QuotaExceeded). - Message(dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ImagePullFailed). - Message(dvRunningCondition.Message) - ds.recorder.Event(vi, corev1.EventTypeWarning, vicondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - ds.recorder.Event( - vi, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncCompleted, - "The ObjectRef DataSource import has completed", - ) - - vi.Status.Phase = v1alpha2.ImageReady - cb. - Status(metav1.ConditionTrue). - Reason(vicondition.Ready). - Message("") - - var dvcrDataSource controller.DVCRDataSource - dvcrDataSource, err = controller.NewDVCRDataSourcesForVMI(ctx, vi.Spec.DataSource, vi, ds.client) - if err != nil { - return reconcile.Result{}, err - } - - vi.Status.Size = dvcrDataSource.GetSize() - vi.Status.CDROM = dvcrDataSource.IsCDROM() - vi.Status.Format = dvcrDataSource.GetFormat() - vi.Status.Progress = "100%" - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - vi.Status.Progress = ds.diskService.GetProgress(dv, vi.Status.Progress, service.NewScaleOption(0, 100)) - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - - err = ds.diskService.Protect(ctx, supgen, vi, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } - - err = setPhaseConditionForPVCProvisioningImage(ctx, dv, vi, pvc, cb, ds.diskService) - if err != nil { - return reconcile.Result{}, err - } - - return reconcile.Result{}, nil + return reconcilePVCImportFromReadySource(ctx, vi, pvc, source, diskSize, cb, supgen, ds.statService, ds.diskService, func() { + ds.recorder.Event(vi, corev1.EventTypeNormal, v1alpha2.ReasonDataSourceSyncCompleted, "The ObjectRef DataSource import has completed") + vi.Status.Size = dvcrDataSource.GetSize() + vi.Status.CDROM = dvcrDataSource.IsCDROM() + }) } return reconcile.Result{RequeueAfter: time.Second}, nil @@ -362,6 +268,7 @@ func (ds ObjectRefDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.Virt return CleanUpSupplements(ctx, vi, ds) case object.IsTerminating(pod): vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Progress = "" log.Info("Cleaning up...") case pod == nil: @@ -458,6 +365,7 @@ func (ds ObjectRefDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.Virt Message("Import is in the process of provisioning to DVCR.") vi.Status.Phase = v1alpha2.ImageProvisioning + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress), 100) vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) log.Info("Ready", "progress", vi.Status.Progress, "pod.phase", pod.Status.Phase) @@ -599,10 +507,10 @@ func (ds ObjectRefDataSource) getPVCSize(dvcrDataSource controller.DVCRDataSourc return resource.Quantity{}, errors.New("got zero unpacked size from data source") } - return service.GetValidatedPVCSize(&unpackedSize, unpackedSize) + return service.GetValidatedPVCSize(nil, unpackedSize) } -func (ds ObjectRefDataSource) getSource(sup supplements.Generator, dvcrDataSource controller.DVCRDataSource) (*cdiv1.DataVolumeSource, error) { +func (ds ObjectRefDataSource) getSource(sup supplements.Generator, dvcrDataSource controller.DVCRDataSource) (*service.PVCImportSource, error) { if !dvcrDataSource.IsReady() { return nil, errors.New("dvcr data source is not ready") } @@ -611,11 +519,5 @@ func (ds ObjectRefDataSource) getSource(sup supplements.Generator, dvcrDataSourc secretName := sup.DVCRAuthSecretForDV().Name certConfigMapName := sup.DVCRCABundleConfigMapForDV().Name - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - }, nil + return service.NewPVCRegistryImportSource(url, secretName, certConfigMapName), nil } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vd.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vd.go index 9a0840fe42..b69cbdff4c 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vd.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vd.go @@ -24,19 +24,16 @@ import ( "time" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" "github.com/deckhouse/virtualization-controller/pkg/common/datasource" - "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" "github.com/deckhouse/virtualization-controller/pkg/common/object" podutil "github.com/deckhouse/virtualization-controller/pkg/common/pod" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" @@ -53,6 +50,7 @@ import ( type ObjectRefVirtualDisk struct { importerService Importer + bounderService Bounder diskService *service.DiskService statService Stat dvcrSettings *dvcr.Settings @@ -63,6 +61,7 @@ type ObjectRefVirtualDisk struct { func NewObjectRefVirtualDisk( recorder eventrecord.EventRecorderLogger, importerService Importer, + bounderService Bounder, client client.Client, diskService *service.DiskService, dvcrSettings *dvcr.Settings, @@ -70,6 +69,7 @@ func NewObjectRefVirtualDisk( ) *ObjectRefVirtualDisk { return &ObjectRefVirtualDisk{ importerService: importerService, + bounderService: bounderService, client: client, recorder: recorder, diskService: diskService, @@ -107,10 +107,13 @@ func (ds ObjectRefVirtualDisk) StoreToDVCR(ctx context.Context, vi *v1alpha2.Vir return CleanUpSupplements(ctx, vi, ds) case object.IsTerminating(pod): vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Progress = "" log.Info("Cleaning up...") case pod == nil: - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) pvc := &corev1.PersistentVolumeClaim{} @@ -209,7 +212,7 @@ func (ds ObjectRefVirtualDisk) StoreToDVCR(ctx context.Context, vi *v1alpha2.Vir Message("Import is in the process of provisioning to DVCR.") vi.Status.Phase = v1alpha2.ImageProvisioning - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress), 100) vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) log.Info("Provisioning...", "progress", vi.Status.Progress, "pod.phase", pod.Status.Phase) @@ -222,23 +225,11 @@ func (ds ObjectRefVirtualDisk) StoreToPVC(ctx context.Context, vi *v1alpha2.Virt log, ctx := logger.GetDataSourceContext(ctx, objectRefDataSource) supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } - pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { return reconcile.Result{}, err } - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) - } - condition, _ := conditions.GetCondition(vicondition.ReadyType, vi.Status.Conditions) switch { case IsImageProvisioningFinished(condition): @@ -246,21 +237,15 @@ func (ds ObjectRefVirtualDisk) StoreToPVC(ctx context.Context, vi *v1alpha2.Virt setPhaseConditionForFinishedImage(pvc, cb, &vi.Status.Phase, supgen) - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen, vi, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - - err = ds.diskService.Unprotect(ctx, supgen, dv) + err = ds.diskService.Unprotect(ctx, supgen) if err != nil { return reconcile.Result{}, err } return CleanUpSupplements(ctx, vi, ds) - case object.AnyTerminating(dv, pvc): + case object.AnyTerminating(pvc): log.Info("Waiting for supplements to be terminated") - case dv == nil: + default: ds.recorder.Event( vi, corev1.EventTypeNormal, @@ -268,15 +253,12 @@ func (ds ObjectRefVirtualDisk) StoreToPVC(ctx context.Context, vi *v1alpha2.Virt "The ObjectRef DataSource import has started", ) - vi.Status.Progress = "0%" + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } vi.Status.SourceUID = ptr.To(vdRef.GetUID()) - source := &cdiv1.DataVolumeSource{ - PVC: &cdiv1.DataVolumeSourcePVC{ - Name: vdRef.Status.Target.PersistentVolumeClaim, - Namespace: vdRef.Namespace, - }, - } + source := service.NewPVCPVCImportSource(vdRef.Status.Target.PersistentVolumeClaim, vdRef.Namespace) var size resource.Quantity size, err = resource.ParseQuantity(vdRef.Status.Capacity) @@ -284,97 +266,31 @@ func (ds ObjectRefVirtualDisk) StoreToPVC(ctx context.Context, vi *v1alpha2.Virt return reconcile.Result{}, err } - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vi.Status.StorageClassName) - if err != nil { - return reconcile.Result{}, err - } - err = ds.diskService.StartImmediate(ctx, size, sc, source, vi, supgen) - if updated, err := setPhaseConditionFromStorageError(err, vi, cb); err != nil || updated { - return reconcile.Result{}, err - } - - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.QuotaExceeded). - Message(dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ImagePullFailed). - Message(dvRunningCondition.Message) - ds.recorder.Event(vi, corev1.EventTypeWarning, vicondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - ds.recorder.Event( - vi, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncCompleted, - "The ObjectRef DataSource import has completed", - ) - - vi.Status.Phase = v1alpha2.ImageReady - cb. - Status(metav1.ConditionTrue). - Reason(vicondition.Ready). - Message("") - - q, err := resource.ParseQuantity(vdRef.Status.Capacity) - if err != nil { - return reconcile.Result{}, err - } - - intQ, ok := q.AsInt64() - if !ok { - return reconcile.Result{}, errors.New("fail to convert quantity to int64") - } - - vi.Status.Size = v1alpha2.ImageStatusSize{ - Stored: vdRef.Status.Capacity, - StoredBytes: strconv.FormatInt(intQ, 10), - Unpacked: vdRef.Status.Capacity, - UnpackedBytes: strconv.FormatInt(intQ, 10), - } - - vi.Status.Format = imageformat.FormatRAW - vi.Status.Progress = "100%" - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - vi.Status.Progress = ds.diskService.GetProgress(dv, vi.Status.Progress, service.NewScaleOption(0, 100)) - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - - err = ds.diskService.Protect(ctx, supgen, vi, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } - - err = setPhaseConditionForPVCProvisioningImage(ctx, dv, vi, pvc, cb, ds.diskService) - if err != nil { + // A same-CSI PVC source is provisioned via a smart clone (no importer pod). + // On a WaitForFirstConsumer storage class the target PVC needs a consumer to + // bind; create a bounder pod to trigger it, since a VirtualImage never gets a + // VirtualMachine consumer of its own. + if err = ds.ensureBounder(ctx, vi, pvc, supgen); err != nil { return reconcile.Result{}, err } - return reconcile.Result{}, nil + return reconcilePVCImportFromReadySource(ctx, vi, pvc, source, size, cb, supgen, ds.statService, ds.diskService, func() { + ds.recorder.Event(vi, corev1.EventTypeNormal, v1alpha2.ReasonDataSourceSyncCompleted, "The ObjectRef DataSource import has completed") + q, err := resource.ParseQuantity(vdRef.Status.Capacity) + if err != nil { + return + } + intQ, ok := q.AsInt64() + if !ok { + return + } + vi.Status.Size = v1alpha2.ImageStatusSize{ + Stored: vdRef.Status.Capacity, + StoredBytes: strconv.FormatInt(intQ, 10), + Unpacked: vdRef.Status.Capacity, + UnpackedBytes: strconv.FormatInt(intQ, 10), + } + }) } return reconcile.Result{RequeueAfter: time.Second}, nil @@ -388,18 +304,37 @@ func (ds ObjectRefVirtualDisk) CleanUpSupplements(ctx context.Context, vi *v1alp return reconcile.Result{}, err } + bounderRequeue, err := ds.bounderService.CleanUpSupplements(ctx, supgen) + if err != nil { + return reconcile.Result{}, err + } + diskRequeue, err := ds.diskService.CleanUpSupplements(ctx, supgen) if err != nil { return reconcile.Result{}, err } - if importerRequeue || diskRequeue { + if importerRequeue || bounderRequeue || diskRequeue { return reconcile.Result{RequeueAfter: time.Second}, nil } else { return reconcile.Result{}, nil } } +// ensureBounder creates a bounder pod when the target PVC is a smart clone on a +// WaitForFirstConsumer storage class, so its provisioning is triggered. The +// bounder pod is quota-excluded and owned by the target, and is removed by +// CleanUpSupplements once the image is Ready. +func (ds ObjectRefVirtualDisk) ensureBounder(ctx context.Context, vi *v1alpha2.VirtualImage, pvc *corev1.PersistentVolumeClaim, supgen supplements.Generator) error { + need, err := needBounderForClone(ctx, ds.client, pvc) + if err != nil || !need { + return err + } + + ownerRef := metav1.NewControllerRef(vi, vi.GroupVersionKind()) + return ds.bounderService.Start(ctx, ownerRef, supgen, service.WithSystemNodeToleration()) +} + func (ds ObjectRefVirtualDisk) CleanUp(ctx context.Context, vi *v1alpha2.VirtualImage) (bool, error) { supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) @@ -408,12 +343,17 @@ func (ds ObjectRefVirtualDisk) CleanUp(ctx context.Context, vi *v1alpha2.Virtual return false, err } + bounderRequeue, err := ds.bounderService.CleanUp(ctx, supgen) + if err != nil { + return false, err + } + diskRequeue, err := ds.diskService.CleanUp(ctx, supgen) if err != nil { return false, err } - return importerRequeue || diskRequeue, nil + return importerRequeue || bounderRequeue || diskRequeue, nil } func (ds ObjectRefVirtualDisk) getEnvSettings(vi *v1alpha2.VirtualImage, sup supplements.Generator, volumeMode *corev1.PersistentVolumeMode) *importer.Settings { diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_cr.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_cr.go index 176269f2c6..f71c0fe165 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_cr.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_cr.go @@ -22,14 +22,18 @@ import ( "fmt" vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" "github.com/deckhouse/virtualization-controller/pkg/common/steptaker" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" "github.com/deckhouse/virtualization-controller/pkg/controller/vi/internal/source/step" "github.com/deckhouse/virtualization-controller/pkg/dvcr" @@ -85,10 +89,17 @@ func (ds ObjectRefVirtualDiskSnapshotCR) Sync(ctx context.Context, vi *v1alpha2. return reconcile.Result{}, fmt.Errorf("fetch pod: %w", err) } + var pvcSvc interface { + CreateTargetFromVS(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) + } + if pvc == nil && vi.Status.Target.PersistentVolumeClaim == "" { + pvcSvc = ds.diskService.PersistentVolumeClaim() + } + return steptaker.NewStepTakers[*v1alpha2.VirtualImage]( step.NewReadyContainerRegistryStep(pod, ds.importer, ds.diskService, ds.stat, ds.recorder, cb), step.NewTerminatingStep(pvc), - step.NewCreatePersistentVolumeClaimStep(pvc, ds.recorder, ds.client, cb), + step.NewCreatePersistentVolumeClaimStep(pvc, ds.diskService, pvcSvc, ds.recorder, ds.client, cb), step.NewCreatePodStep(pod, ds.client, ds.dvcrSettings, ds.recorder, ds.importer, ds.stat, cb), step.NewWaitForPodStep(pod, pvc, ds.stat, cb), ).Run(ctx, vi) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_cr_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_cr_test.go index 65b2014975..2ff04e8246 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_cr_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_cr_test.go @@ -119,6 +119,9 @@ var _ = Describe("ObjectRef VirtualImageSnapshot ContainerRegistry", func() { GetPersistentVolumeClaimFunc: func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { return pvc, nil }, + GetVolumeAndAccessModesFunc: func(_ context.Context, _ client.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil + }, } settings = &dvcr.Settings{} @@ -132,6 +135,9 @@ var _ = Describe("ObjectRef VirtualImageSnapshot ContainerRegistry", func() { vs = &vsv1.VolumeSnapshot{ ObjectMeta: metav1.ObjectMeta{ Name: "vs", + Annotations: map[string]string{ + annotations.AnnStorageClassName: sc.Name, + }, }, Status: &vsv1.VolumeSnapshotStatus{ ReadyToUse: ptr.To(true), @@ -221,7 +227,7 @@ var _ = Describe("ObjectRef VirtualImageSnapshot ContainerRegistry", func() { } vi.Status = v1alpha2.VirtualImageStatus{} - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs, vd, pvc). + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs, vd, pvc, sc). WithInterceptorFuncs(interceptor.Funcs{ Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { switch obj.(type) { @@ -232,6 +238,9 @@ var _ = Describe("ObjectRef VirtualImageSnapshot ContainerRegistry", func() { return nil }, }).Build() + diskService.PersistentVolumeClaimFunc = func() *service.PersistentVolumeClaimService { + return service.NewPersistentVolumeClaimService(client, nil, nil, service.DiskImporterConfig{}) + } syncer := NewObjectRefVirtualDiskSnapshotCR(importer, stat, diskService, client, settings, recorder) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc.go index 794db7eaa0..abf060bcc7 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc.go @@ -21,12 +21,18 @@ import ( "errors" "fmt" + vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" "github.com/deckhouse/virtualization-controller/pkg/common/steptaker" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" "github.com/deckhouse/virtualization-controller/pkg/controller/vi/internal/source/step" "github.com/deckhouse/virtualization-controller/pkg/dvcr" @@ -80,10 +86,17 @@ func (ds ObjectRefVirtualDiskSnapshotPVC) Sync(ctx context.Context, vi *v1alpha2 return reconcile.Result{}, fmt.Errorf("fetch pvc: %w", err) } + var pvcSvc interface { + CreateTargetFromVS(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) + } + if pvc == nil && vi.Status.Target.PersistentVolumeClaim == "" { + pvcSvc = ds.diskService.PersistentVolumeClaim() + } + return steptaker.NewStepTakers[*v1alpha2.VirtualImage]( step.NewReadyPersistentVolumeClaimStep(pvc, ds.bounder, ds.recorder, cb), step.NewTerminatingStep(pvc), - step.NewCreatePersistentVolumeClaimStep(pvc, ds.recorder, ds.client, cb), + step.NewCreatePersistentVolumeClaimStep(pvc, ds.diskService, pvcSvc, ds.recorder, ds.client, cb), step.NewCreateBounderPodStep(pvc, ds.bounder, ds.client, ds.recorder, cb), step.NewWaitForPVCStep(pvc, cb), ).Run(ctx, vi) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc_test.go index 0a6cf97dbb..10fa2db359 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vdsnapshot_pvc_test.go @@ -113,6 +113,9 @@ var _ = Describe("ObjectRef VirtualImageSnapshot PersistentVolumeClaim", func() } return nil, nil }, + GetVolumeAndAccessModesFunc: func(_ context.Context, _ client.Object, _ *storagev1.StorageClass) (corev1.PersistentVolumeMode, corev1.PersistentVolumeAccessMode, error) { + return corev1.PersistentVolumeFilesystem, corev1.ReadWriteOnce, nil + }, } settings = &dvcr.Settings{} @@ -125,6 +128,9 @@ var _ = Describe("ObjectRef VirtualImageSnapshot PersistentVolumeClaim", func() vs = &vsv1.VolumeSnapshot{ ObjectMeta: metav1.ObjectMeta{ Name: "vs", + Annotations: map[string]string{ + annotations.AnnStorageClassName: sc.Name, + }, }, Status: &vsv1.VolumeSnapshotStatus{ ReadyToUse: ptr.To(true), @@ -189,7 +195,7 @@ var _ = Describe("ObjectRef VirtualImageSnapshot PersistentVolumeClaim", func() var pvcCreated bool vi.Status = v1alpha2.VirtualImageStatus{} - client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs). + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vdSnapshot, vs, sc). WithInterceptorFuncs(interceptor.Funcs{ Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { switch obj.(type) { @@ -200,6 +206,9 @@ var _ = Describe("ObjectRef VirtualImageSnapshot PersistentVolumeClaim", func() return nil }, }).Build() + diskService.PersistentVolumeClaimFunc = func() *service.PersistentVolumeClaimService { + return service.NewPersistentVolumeClaimService(client, nil, nil, service.DiskImporterConfig{}) + } diskService.GetPersistentVolumeClaimFunc = func(_ context.Context, _ supplements.Generator) (*corev1.PersistentVolumeClaim, error) { return nil, nil diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vi_on_pvc.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vi_on_pvc.go index ad56a5175c..d42823b962 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vi_on_pvc.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/object_ref_vi_on_pvc.go @@ -23,11 +23,9 @@ import ( "time" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -50,6 +48,7 @@ import ( type ObjectRefDataVirtualImageOnPVC struct { statService Stat importerService Importer + bounderService Bounder dvcrSettings *dvcr.Settings client client.Client diskService *service.DiskService @@ -60,6 +59,7 @@ func NewObjectRefDataVirtualImageOnPVC( recorder eventrecord.EventRecorderLogger, statService Stat, importerService Importer, + bounderService Bounder, dvcrSettings *dvcr.Settings, client client.Client, diskService *service.DiskService, @@ -67,6 +67,7 @@ func NewObjectRefDataVirtualImageOnPVC( return &ObjectRefDataVirtualImageOnPVC{ statService: statService, importerService: importerService, + bounderService: bounderService, dvcrSettings: dvcrSettings, client: client, diskService: diskService, @@ -103,10 +104,13 @@ func (ds ObjectRefDataVirtualImageOnPVC) StoreToDVCR(ctx context.Context, vi, vi return CleanUpSupplements(ctx, vi, ds) case object.IsTerminating(pod): vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Progress = "" log.Info("Cleaning up...") case pod == nil: - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) envSettings := ds.getEnvSettings(vi, supgen) @@ -158,7 +162,12 @@ func (ds ObjectRefDataVirtualImageOnPVC) StoreToDVCR(ctx context.Context, vi, vi Message("") vi.Status.Phase = v1alpha2.ImageReady - vi.Status.Size = viRef.Status.Size + // Report the size of the image actually produced in DVCR (measured by the + // importer), not the source VI's reported size. A raw block-device source + // (a VirtualImage on PVC) exposes the full, possibly over-allocated device + // size, so the converted image can be larger than the source's nominal + // size; copying the source size would under-size any downstream PVC. + vi.Status.Size = ds.statService.GetSize(pod) vi.Status.CDROM = viRef.Status.CDROM vi.Status.Format = viRef.Status.Format vi.Status.Progress = "100%" @@ -182,7 +191,7 @@ func (ds ObjectRefDataVirtualImageOnPVC) StoreToDVCR(ctx context.Context, vi, vi Message("Import is in the process of provisioning to DVCR.") vi.Status.Phase = v1alpha2.ImageProvisioning - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress), 100) vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) log.Info("Provisioning...", "progress", vi.Status.Progress, "pod.phase", pod.Status.Phase) @@ -195,22 +204,11 @@ func (ds ObjectRefDataVirtualImageOnPVC) StoreToPVC(ctx context.Context, vi, viR log, _ := logger.GetDataSourceContext(ctx, objectRefDataSource) supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { return reconcile.Result{}, err } - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) - } - condition, _ := conditions.GetCondition(vicondition.ReadyType, vi.Status.Conditions) switch { case IsImageProvisioningFinished(condition): @@ -218,21 +216,15 @@ func (ds ObjectRefDataVirtualImageOnPVC) StoreToPVC(ctx context.Context, vi, viR setPhaseConditionForFinishedImage(pvc, cb, &vi.Status.Phase, supgen) - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen, vi, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - - err = ds.diskService.Unprotect(ctx, supgen, dv) + err = ds.diskService.Unprotect(ctx, supgen) if err != nil { return reconcile.Result{}, err } return CleanUpSupplements(ctx, vi, ds) - case object.AnyTerminating(dv, pvc): + case object.AnyTerminating(pvc): log.Info("Waiting for supplements to be terminated") - case dv == nil: + default: ds.recorder.Event( vi, corev1.EventTypeNormal, @@ -240,7 +232,9 @@ func (ds ObjectRefDataVirtualImageOnPVC) StoreToPVC(ctx context.Context, vi, viR "The ObjectRef DataSource import has started", ) - vi.Status.Progress = "0%" + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } vi.Status.SourceUID = ptr.To(viRef.GetUID()) var size resource.Quantity @@ -255,91 +249,38 @@ func (ds ObjectRefDataVirtualImageOnPVC) StoreToPVC(ctx context.Context, vi, viR return reconcile.Result{}, err } - source := &cdiv1.DataVolumeSource{ - PVC: &cdiv1.DataVolumeSourcePVC{ - Name: viRef.Status.Target.PersistentVolumeClaim, - Namespace: viRef.Namespace, - }, - } + source := service.NewPVCPVCImportSource(viRef.Status.Target.PersistentVolumeClaim, viRef.Namespace) - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vi.Status.StorageClassName) - if err != nil { + // A same-CSI PVC source is provisioned via a smart clone (no importer pod). + // On a WaitForFirstConsumer storage class the target PVC needs a consumer to + // bind; create a bounder pod to trigger it, since a VirtualImage never gets a + // VirtualMachine consumer of its own. + if err = ds.ensureBounder(ctx, vi, pvc, supgen); err != nil { return reconcile.Result{}, err } - err = ds.diskService.StartImmediate(ctx, size, sc, source, vi, supgen) - if updated, err := setPhaseConditionFromStorageError(err, vi, cb); err != nil || updated { - return reconcile.Result{}, err - } - - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.QuotaExceeded). - Message(dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ImagePullFailed). - Message(dvRunningCondition.Message) - ds.recorder.Event(vi, corev1.EventTypeWarning, vicondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - ds.recorder.Event( - vi, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncCompleted, - "The ObjectRef DataSource import has completed", - ) - - vi.Status.Phase = v1alpha2.ImageReady - cb. - Status(metav1.ConditionTrue). - Reason(vicondition.Ready). - Message("") - vi.Status.Size = viRef.Status.Size - vi.Status.CDROM = viRef.Status.CDROM - vi.Status.Format = viRef.Status.Format - vi.Status.Progress = "100%" - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - vi.Status.Progress = ds.diskService.GetProgress(dv, vi.Status.Progress, service.NewScaleOption(0, 100)) - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - err = ds.diskService.Protect(ctx, supgen, vi, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } + return reconcilePVCImportFromReadySource(ctx, vi, pvc, source, size, cb, supgen, ds.statService, ds.diskService, func() { + ds.recorder.Event(vi, corev1.EventTypeNormal, v1alpha2.ReasonDataSourceSyncCompleted, "The ObjectRef DataSource import has completed") + vi.Status.Size = viRef.Status.Size + vi.Status.CDROM = viRef.Status.CDROM + }) + } - err = setPhaseConditionForPVCProvisioningImage(ctx, dv, vi, pvc, cb, ds.diskService) - if err != nil { - return reconcile.Result{}, err - } + return reconcile.Result{RequeueAfter: time.Second}, nil +} - return reconcile.Result{}, nil +// ensureBounder creates a bounder pod when the target PVC is a smart clone on a +// WaitForFirstConsumer storage class, so its provisioning is triggered. The +// bounder pod is quota-excluded and owned by the target, and is removed by +// CleanUpSupplements once the image is Ready. +func (ds ObjectRefDataVirtualImageOnPVC) ensureBounder(ctx context.Context, vi *v1alpha2.VirtualImage, pvc *corev1.PersistentVolumeClaim, supgen supplements.Generator) error { + need, err := needBounderForClone(ctx, ds.client, pvc) + if err != nil || !need { + return err } - return reconcile.Result{RequeueAfter: time.Second}, nil + ownerRef := metav1.NewControllerRef(vi, vi.GroupVersionKind()) + return ds.bounderService.Start(ctx, ownerRef, supgen, service.WithSystemNodeToleration()) } func (ds ObjectRefDataVirtualImageOnPVC) CleanUp(ctx context.Context, vi *v1alpha2.VirtualImage) (bool, error) { @@ -350,12 +291,17 @@ func (ds ObjectRefDataVirtualImageOnPVC) CleanUp(ctx context.Context, vi *v1alph return false, err } + bounderRequeue, err := ds.bounderService.CleanUp(ctx, supgen) + if err != nil { + return false, err + } + diskRequeue, err := ds.diskService.CleanUp(ctx, supgen) if err != nil { return false, err } - return importerRequeue || diskRequeue, nil + return importerRequeue || bounderRequeue || diskRequeue, nil } func (ds ObjectRefDataVirtualImageOnPVC) getEnvSettings(vi *v1alpha2.VirtualImage, sup supplements.Generator) *importer.Settings { @@ -379,12 +325,17 @@ func (ds ObjectRefDataVirtualImageOnPVC) CleanUpSupplements(ctx context.Context, return reconcile.Result{}, err } + bounderRequeue, err := ds.bounderService.CleanUpSupplements(ctx, supgen) + if err != nil { + return reconcile.Result{}, err + } + diskRequeue, err := ds.diskService.CleanUpSupplements(ctx, supgen) if err != nil { return reconcile.Result{}, err } - if importerRequeue || diskRequeue { + if importerRequeue || bounderRequeue || diskRequeue { return reconcile.Result{RequeueAfter: time.Second}, nil } else { return reconcile.Result{}, nil @@ -401,5 +352,5 @@ func (ds ObjectRefDataVirtualImageOnPVC) getPVCSize(refSize v1alpha2.ImageStatus return resource.Quantity{}, errors.New("got zero unpacked size from data source") } - return service.GetValidatedPVCSize(&unpackedSize, unpackedSize) + return service.GetValidatedPVCSize(nil, unpackedSize) } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/registry.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/registry.go index 6669fc7f6b..f7ffcee4df 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/registry.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/registry.go @@ -23,11 +23,9 @@ import ( "time" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -88,47 +86,30 @@ func (ds RegistryDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.Virtua if err != nil { return reconcile.Result{}, err } - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { return reconcile.Result{}, err } - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) - } - switch { case IsImageProvisioningFinished(condition): log.Info("Disk provisioning finished: clean up") setPhaseConditionForFinishedImage(pvc, cb, &vi.Status.Phase, supgen) - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen, vi, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - // Unprotect import time supplements to delete them later. err = ds.importerService.Unprotect(ctx, pod, supgen) if err != nil { return reconcile.Result{}, err } - err = ds.diskService.Unprotect(ctx, supgen, dv) + err = ds.diskService.Unprotect(ctx, supgen) if err != nil { return reconcile.Result{}, err } return CleanUpSupplements(ctx, vi, ds) - case object.AnyTerminating(pod, dv, pvc): + case object.AnyTerminating(pod, pvc): log.Info("Waiting for supplements to be terminated") case pod == nil: log.Info("Start import to DVCR") @@ -169,118 +150,16 @@ func (ds RegistryDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.Virtua Reason(vicondition.Provisioning). Message("DVCR Provisioner not found: create the new one.") - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress, service.NewScaleOption(0, 50)) + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress, service.NewScaleOption(0, 50)), 100) err = ds.importerService.Protect(ctx, pod, supgen) if err != nil { return reconcile.Result{}, err } - case dv == nil: + default: log.Info("Start import to PVC") - - err = ds.statService.CheckPod(pod) - if err != nil { - vi.Status.Phase = v1alpha2.ImageFailed - - switch { - case errors.Is(err, service.ErrProvisioningFailed): - ds.recorder.Event(vi, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceDiskProvisioningFailed, "Disk provisioning failed") - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ProvisioningFailed). - Message(service.CapitalizeFirstLetter(err.Error() + ".")) - return reconcile.Result{}, nil - default: - return reconcile.Result{}, err - } - } - - vi.Status.Progress = "50.0%" - - var diskSize resource.Quantity - diskSize, err = ds.getPVCSize(pod) - if err != nil { - setPhaseConditionToFailed(cb, &vi.Status.Phase, err) - - if errors.Is(err, service.ErrInsufficientPVCSize) { - return reconcile.Result{}, nil - } - - return reconcile.Result{}, err - } - source := ds.getSource(supgen, ds.statService.GetDVCRImageName(pod)) - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vi.Status.StorageClassName) - if err != nil { - return reconcile.Result{}, err - } - err = ds.diskService.StartImmediate(ctx, diskSize, sc, source, vi, supgen) - if updated, err := setPhaseConditionFromStorageError(err, vi, cb); err != nil || updated { - return reconcile.Result{}, err - } - - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.QuotaExceeded). - Message(dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ImagePullFailed). - Message(dvRunningCondition.Message) - ds.recorder.Event(vi, corev1.EventTypeWarning, vicondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - vi.Status.Phase = v1alpha2.ImageReady - cb. - Status(metav1.ConditionTrue). - Reason(vicondition.Ready). - Message("") - - vi.Status.Progress = "100%" - vi.Status.Size = ds.statService.GetSize(pod) - vi.Status.CDROM = ds.statService.GetCDROM(pod) - vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - vi.Status.Progress = ds.diskService.GetProgress(dv, vi.Status.Progress, service.NewScaleOption(50, 100)) - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - - err = ds.diskService.Protect(ctx, supgen, vi, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } - - err = setPhaseConditionForPVCProvisioningImage(ctx, dv, vi, pvc, cb, ds.diskService) - if err != nil { - return reconcile.Result{}, err - } - - return reconcile.Result{}, nil + return reconcilePVCImportFromDVCR(ctx, vi, pod, pvc, source, cb, supgen, ds.statService, ds.diskService) } return reconcile.Result{RequeueAfter: time.Second}, nil @@ -318,6 +197,7 @@ func (ds RegistryDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.Virtu return CleanUpSupplements(ctx, vi, ds) case object.IsTerminating(pod): vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Progress = "" log.Info("Cleaning up...") case pod == nil: @@ -395,7 +275,7 @@ func (ds RegistryDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.Virtu Message("Import is in the process of provisioning to DVCR.") vi.Status.Phase = v1alpha2.ImageProvisioning - vi.Status.Progress = "0%" + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress), 100) vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) log.Info("Provisioning...", "progress", vi.Status.Progress, "pod.phase", pod.Status.Phase) @@ -492,10 +372,10 @@ func (ds RegistryDataSource) getPVCSize(pod *corev1.Pod) (resource.Quantity, err return resource.Quantity{}, errors.New("got zero unpacked size from data source") } - return service.GetValidatedPVCSize(&unpackedSize, unpackedSize) + return service.GetValidatedPVCSize(nil, unpackedSize) } -func (ds RegistryDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *cdiv1.DataVolumeSource { +func (ds RegistryDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *service.PVCImportSource { // The image was preloaded from source into dvcr. // We can't use the same data source a second time, but we can set dvcr as the data source. // Use DV name for the Secret with DVCR auth and the ConfigMap with DVCR CA Bundle. @@ -503,11 +383,5 @@ func (ds RegistryDataSource) getSource(sup supplements.Generator, dvcrSourceImag secretName := sup.DVCRAuthSecretForDV().Name certConfigMapName := sup.DVCRCABundleConfigMapForDV().Name - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - } + return service.NewPVCRegistryImportSource(url, secretName, certConfigMapName) } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/source_additional_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/source_additional_test.go index 48ef06ecc4..a24ec26bd1 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/source_additional_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/source_additional_test.go @@ -174,19 +174,19 @@ var _ = Describe("Source validations and helpers", func() { }) It("constructs syncer", func() { - syncer := NewObjectRefVirtualDisk(newRecorder(), nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) Expect(syncer).ToNot(BeNil()) }) It("returns error for non virtual disk source", func() { vi.Spec.DataSource.ObjectRef.Kind = v1alpha2.VirtualImageObjectRefKindVirtualImage - syncer := NewObjectRefVirtualDisk(newRecorder(), nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) Expect(syncer.Validate(ctx, vi)).To(MatchError("not a VirtualDisk data source")) }) It("returns not ready when virtual disk is absent", func() { - syncer := NewObjectRefVirtualDisk(newRecorder(), nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) Expect(syncer.Validate(ctx, vi)).To(MatchError("VirtualDisk vd not ready")) }) @@ -205,7 +205,7 @@ var _ = Describe("Source validations and helpers", func() { }, } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() - syncer := NewObjectRefVirtualDisk(newRecorder(), nil, client, nil, settings, nil) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, nil, client, nil, settings, nil) Expect(syncer.Validate(ctx, vi)).To(MatchError("the VirtualDisk vd not ready for use")) }) @@ -224,7 +224,7 @@ var _ = Describe("Source validations and helpers", func() { }, } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() - syncer := NewObjectRefVirtualDisk(newRecorder(), nil, client, nil, settings, nil) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, nil, client, nil, settings, nil) Expect(syncer.Validate(ctx, vi)).To(Succeed()) }) @@ -243,7 +243,7 @@ var _ = Describe("Source validations and helpers", func() { }, } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() - syncer := NewObjectRefVirtualDisk(newRecorder(), nil, client, nil, settings, nil) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, nil, client, nil, settings, nil) Expect(syncer.Validate(ctx, vi)).To(MatchError("the VirtualDisk vd attached to VirtualMachine")) }) @@ -255,14 +255,14 @@ var _ = Describe("Source validations and helpers", func() { Status: v1alpha2.VirtualDiskStatus{Phase: v1alpha2.DiskReady}, } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vd).Build() - syncer := NewObjectRefVirtualDisk(newRecorder(), nil, client, nil, settings, nil) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, nil, client, nil, settings, nil) Expect(syncer.Validate(ctx, vi)).To(Succeed()) }) It("builds importer settings for filesystem and block pvc", func() { supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) - syncer := NewObjectRefVirtualDisk(newRecorder(), nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) + syncer := NewObjectRefVirtualDisk(newRecorder(), nil, nil, fake.NewClientBuilder().WithScheme(scheme).Build(), nil, settings, nil) fsSettings := syncer.getEnvSettings(vi, supgen, ptr.To(corev1.PersistentVolumeFilesystem)) blockSettings := syncer.getEnvSettings(vi, supgen, ptr.To(corev1.PersistentVolumeBlock)) @@ -389,7 +389,28 @@ var _ = Describe("Source validations and helpers", func() { source, err := ds.getSource(supgen, dvcrSource) Expect(err).ToNot(HaveOccurred()) - Expect(*source.Registry.URL).To(Equal("docker://registry.example/source")) + Expect(source.Registry.URL).To(Equal("docker://registry.example/source")) + }) + + It("aligns pvc size up to the block boundary for unaligned image sizes", func() { + vi.Spec.DataSource.ObjectRef = &v1alpha2.VirtualImageObjectRef{Kind: v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, Name: "cvi"} + cvi := &v1alpha2.ClusterVirtualImage{ + ObjectMeta: metav1.ObjectMeta{Name: "cvi", UID: "cvi-uid"}, + Status: v1alpha2.ClusterVirtualImageStatus{ + Phase: v1alpha2.ImageReady, + Format: "qcow2", + Size: v1alpha2.ImageStatusSize{UnpackedBytes: "1607484928"}, + Target: v1alpha2.ClusterVirtualImageStatusTarget{RegistryURL: "registry.example/source"}, + }, + } + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cvi).Build() + ds := NewObjectRefDataSource(newRecorder(), nil, nil, nil, settings, client, nil) + dvcrSource, err := controller.NewDVCRDataSourcesForVMI(ctx, vi.Spec.DataSource, vi, client) + Expect(err).ToNot(HaveOccurred()) + + pvcSize, err := ds.getPVCSize(dvcrSource) + Expect(err).ToNot(HaveOccurred()) + Expect(pvcSize.Value()).To(Equal(int64(1608515584))) }) It("rejects not ready dvcr source in helper methods", func() { @@ -441,7 +462,7 @@ var _ = Describe("Source validations and helpers", func() { q, err := registry.getPVCSize(&corev1.Pod{}) Expect(err).ToNot(HaveOccurred()) Expect(q).To(Equal(resource.MustParse("2Gi"))) - Expect(*registry.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) + Expect(registry.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) }) It("covers http helpers", func() { @@ -455,7 +476,7 @@ var _ = Describe("Source validations and helpers", func() { q, err := httpDS.getPVCSize(&corev1.Pod{}) Expect(err).ToNot(HaveOccurred()) Expect(q).To(Equal(resource.MustParse("3Gi"))) - Expect(*httpDS.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) + Expect(httpDS.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) }) It("covers upload helpers", func() { @@ -469,7 +490,7 @@ var _ = Describe("Source validations and helpers", func() { q, err := upload.getPVCSize(&corev1.Pod{}) Expect(err).ToNot(HaveOccurred()) Expect(q).To(Equal(resource.MustParse("4Gi"))) - Expect(*upload.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) + Expect(upload.getSource(supgen, "registry.example/image").Registry.URL).To(Equal("docker://registry.example/image")) }) }) @@ -481,5 +502,17 @@ var _ = Describe("Source validations and helpers", func() { Expect(phase).To(Equal(v1alpha2.ImageFailed)) Expect(cb.Condition().Message).To(Equal("Plain error")) }) + + It("keeps upload status in Provisioning after upload progress appears", func() { + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + vi := newVI() + vi.Status.Progress = "41.9%" + + Expect(hasUploadProgress(vi.Status.Progress)).To(BeTrue()) + setUploadProvisioningPhaseCondition(cb, vi) + + Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageProvisioning)) + Expect(cb.Condition().Reason).To(Equal(vicondition.Provisioning.String())) + }) }) }) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/sources.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/sources.go index 5292845849..71adb72c8d 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/sources.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/sources.go @@ -23,10 +23,16 @@ import ( "time" corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" "github.com/deckhouse/virtualization-controller/pkg/common/object" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/service" @@ -36,6 +42,38 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" ) +// needBounderForClone reports whether a bounder pod must be created to unblock +// provisioning of the target PVC. +// +// A smart-clone target (CSI clone / VolumeSnapshot restore) is dynamically +// provisioned from a dataSource and has no importer pod. On a +// WaitForFirstConsumer storage class such a PVC stays Pending until a consumer +// pod is scheduled. A VirtualImage on PVC never gets a VirtualMachine consumer, +// so without a bounder pod (whose only job is to get scheduled and trigger the +// binding) the import would hang forever. Host-assisted imports bind the target +// via the prime-PVC rebind and never need a bounder. +func needBounderForClone(ctx context.Context, cl client.Client, pvc *corev1.PersistentVolumeClaim) (bool, error) { + if pvc == nil || pvc.Status.Phase == corev1.ClaimBound { + return false, nil + } + if !service.IsSmartClonePVC(pvc) { + return false, nil + } + if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName == "" { + return false, nil + } + + sc, err := object.FetchObject(ctx, types.NamespacedName{Name: *pvc.Spec.StorageClassName}, cl, &storagev1.StorageClass{}) + if err != nil { + return false, fmt.Errorf("fetch storage class: %w", err) + } + if sc == nil || sc.VolumeBindingMode == nil || *sc.VolumeBindingMode != storagev1.VolumeBindingWaitForFirstConsumer { + return false, nil + } + + return true, nil +} + type Handler interface { StoreToDVCR(ctx context.Context, vi *v1alpha2.VirtualImage) (reconcile.Result, error) StoreToPVC(ctx context.Context, vi *v1alpha2.VirtualImage) (reconcile.Result, error) @@ -106,8 +144,58 @@ func IsImageProvisioningFinished(c metav1.Condition) bool { return c.Reason == vicondition.Ready.String() } -type CheckImportProcess interface { - CheckImportProcess(ctx context.Context, dv *cdiv1.DataVolume, pvc *corev1.PersistentVolumeClaim) error +// pvcImportProgressRequeue is how often a VirtualImage on PVC is requeued while +// its import is in flight so that vi.Status.Progress is refreshed from the +// pvc-importer pod metric and streams intermediate percentages. +const pvcImportProgressRequeue = 2 * time.Second + +// refreshPVCImportProgress republishes vi.Status.Progress from the pvc-importer +// pod's kubevirt_cdi_import_progress_total metric while the import to the target +// PVC is running, so the reported progress streams intermediate percentages +// instead of jumping straight from its starting value to 100%. +// +// When scale is set, the metric (0..100) is projected into the +// [scale.Low, scale.High] slice of the overall progress (e.g. 50..100 for the +// HTTP/Registry/Upload data sources whose first half is already filled by the +// DVCR phase). The previous progress is kept untouched when the stat service, +// the pod, or the metric is not yet available. +func refreshPVCImportProgress( + ctx context.Context, + vi *v1alpha2.VirtualImage, + disk *service.DiskService, + stat Stat, + supgen supplements.Generator, + scale *service.ScaleOption, +) error { + if stat == nil { + return nil + } + + pod, err := disk.GetPVCImporterPod(ctx, supgen) + if err != nil { + return fmt.Errorf("fetch pvc-importer pod: %w", err) + } + if pod == nil { + return nil + } + + var opts []service.GetProgressOption + if scale != nil { + opts = append(opts, scale) + } + vi.Status.Progress = service.CapProgressBelow(stat.GetProgress(vi.GetUID(), pod, vi.Status.Progress, opts...), 100) + return nil +} + +func pvcImporterPodPhase(ctx context.Context, disk *service.DiskService, supgen supplements.Generator) (corev1.PodPhase, error) { + pod, err := disk.GetPVCImporterPod(ctx, supgen) + if err != nil { + return "", fmt.Errorf("fetch pvc-importer pod: %w", err) + } + if pod == nil || pod.Status.Phase == "" { + return corev1.PodPending, nil + } + return pod.Status.Phase, nil } func setPhaseConditionForFinishedImage( @@ -140,51 +228,6 @@ func setPhaseConditionToFailed(cb *conditions.ConditionBuilder, phase *v1alpha2. Message(service.CapitalizeFirstLetter(err.Error())) } -func setPhaseConditionForPVCProvisioningImage( - ctx context.Context, - dv *cdiv1.DataVolume, - vi *v1alpha2.VirtualImage, - pvc *corev1.PersistentVolumeClaim, - cb *conditions.ConditionBuilder, - checker CheckImportProcess, -) error { - err := checker.CheckImportProcess(ctx, dv, pvc) - switch { - case err == nil: - if dv == nil { - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("Waiting for the pvc importer to be created") - return nil - } - - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("Import is in the process of provisioning to PVC.") - return nil - case errors.Is(err, service.ErrDataVolumeNotRunning): - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ProvisioningFailed). - Message(service.CapitalizeFirstLetter(err.Error())) - return nil - case errors.Is(err, service.ErrDefaultStorageClassNotFound): - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ProvisioningFailed). - Message("Default StorageClass not found in the cluster: please provide a StorageClass name or set a default StorageClass.") - return nil - default: - return err - } -} - func setPhaseConditionFromPodError(cb *conditions.ConditionBuilder, vi *v1alpha2.VirtualImage, err error) error { vi.Status.Phase = v1alpha2.ImageFailed @@ -219,16 +262,222 @@ func setPhaseConditionFromStorageError(err error, vi *v1alpha2.VirtualImage, cb return true, nil case errors.Is(err, service.ErrDefaultStorageClassNotFound): vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Progress = "" cb. Status(metav1.ConditionFalse). Reason(vicondition.ProvisioningFailed). Message("Default StorageClass not found in the cluster: please provide a StorageClass name or set a default StorageClass.") return true, nil + case common.ErrQuotaExceeded(err): + _ = setQuotaExceededPhaseCondition(cb, &vi.Status.Phase, err, vi.CreationTimestamp) + return true, nil default: return false, err } } +func reconcilePVCImportFromDVCR( + ctx context.Context, + vi *v1alpha2.VirtualImage, + pod *corev1.Pod, + pvc *corev1.PersistentVolumeClaim, + source *service.PVCImportSource, + cb *conditions.ConditionBuilder, + supgen supplements.Generator, + stat Stat, + disk *service.DiskService, +) (reconcile.Result, error) { + if pvc == nil { + if err := stat.CheckPod(pod); err != nil { + vi.Status.Phase = v1alpha2.ImageFailed + switch { + case errors.Is(err, service.ErrProvisioningFailed): + cb. + Status(metav1.ConditionFalse). + Reason(vicondition.ProvisioningFailed). + Message(service.CapitalizeFirstLetter(err.Error() + ".")) + return reconcile.Result{}, nil + default: + return reconcile.Result{}, err + } + } + + vi.Status.Progress = "50.0%" + vi.Status.DownloadSpeed = stat.GetDownloadSpeed(vi.GetUID(), pod) + + diskSize, err := getPVCSizeFromPod(stat, pod) + if err != nil { + setPhaseConditionToFailed(cb, &vi.Status.Phase, err) + if errors.Is(err, service.ErrInsufficientPVCSize) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, err + } + + err = createPVCImportTarget(ctx, vi, supgen, diskSize, source, disk) + if updated, err := setPhaseConditionFromStorageError(err, vi, cb); err != nil || updated { + return reconcile.Result{}, err + } + + target, err := disk.GetPersistentVolumeClaim(ctx, supgen) + if err != nil { + return reconcile.Result{}, fmt.Errorf("fetch target pvc: %w", err) + } + if target == nil { + return reconcile.Result{RequeueAfter: time.Second}, nil + } + vi.Status.Phase = v1alpha2.ImageProvisioning + cb. + Status(metav1.ConditionFalse). + Reason(vicondition.Provisioning). + Message("PVC Provisioner not found: create the new one.") + return reconcile.Result{RequeueAfter: time.Second}, nil + } + + vi.Status.Target.PersistentVolumeClaim = pvc.Name + if pvc.Annotations[annotations.AnnPVCPopulationDone] == "true" { + vi.Status.Phase = v1alpha2.ImageReady + cb.Status(metav1.ConditionTrue).Reason(vicondition.Ready).Message("") + vi.Status.Progress = "100%" + vi.Status.Size = stat.GetSize(pod) + vi.Status.CDROM = stat.GetCDROM(pod) + vi.Status.Format = imageformat.StorageFormat(pvc) + vi.Status.DownloadSpeed = stat.GetDownloadSpeed(vi.GetUID(), pod) + return reconcile.Result{RequeueAfter: time.Second}, nil + } + + importPhase, err := pvcImporterPodPhase(ctx, disk, supgen) + if err != nil { + return reconcile.Result{}, err + } + if importPhase == corev1.PodFailed { + vi.Status.Phase = v1alpha2.ImageFailed + cb.Status(metav1.ConditionFalse).Reason(vicondition.ProvisioningFailed).Message("VirtualImage importer Pod failed.") + return reconcile.Result{}, nil + } + + vi.Status.Phase = v1alpha2.ImageProvisioning + cb.Status(metav1.ConditionFalse).Reason(vicondition.Provisioning).Message("Import is in the process of provisioning to PVC.") + if vi.Status.Progress == "" { + vi.Status.Progress = "50.0%" + } + if importPhase == corev1.PodSucceeded { + return reconcile.Result{RequeueAfter: pvcImportProgressRequeue}, nil + } + // The DVCR phase fills the first half of the overall progress, so the + // pvc-importer metric (0..100) is projected into the 50..100 slice. + if err := refreshPVCImportProgress(ctx, vi, disk, stat, supgen, service.NewScaleOption(50, 100)); err != nil { + return reconcile.Result{}, err + } + return reconcile.Result{RequeueAfter: pvcImportProgressRequeue}, nil +} + +func getPVCSizeFromPod(stat Stat, pod *corev1.Pod) (resource.Quantity, error) { + unpackedSize, err := resource.ParseQuantity(stat.GetSize(pod).UnpackedBytes) + if err != nil { + return resource.Quantity{}, fmt.Errorf("failed to parse unpacked bytes %s: %w", stat.GetSize(pod).UnpackedBytes, err) + } + if unpackedSize.IsZero() { + return resource.Quantity{}, errors.New("got zero unpacked size from data source") + } + return service.GetValidatedPVCSize(nil, unpackedSize) +} + +func reconcilePVCImportFromReadySource( + ctx context.Context, + vi *v1alpha2.VirtualImage, + pvc *corev1.PersistentVolumeClaim, + source *service.PVCImportSource, + size resource.Quantity, + cb *conditions.ConditionBuilder, + supgen supplements.Generator, + stat Stat, + disk *service.DiskService, + ready func(), +) (reconcile.Result, error) { + if pvc == nil { + err := createPVCImportTarget(ctx, vi, supgen, size, source, disk) + if updated, err := setPhaseConditionFromStorageError(err, vi, cb); err != nil || updated { + return reconcile.Result{}, err + } + + target, err := disk.GetPersistentVolumeClaim(ctx, supgen) + if err != nil { + return reconcile.Result{}, fmt.Errorf("fetch target pvc: %w", err) + } + if target == nil { + return reconcile.Result{RequeueAfter: time.Second}, nil + } + vi.Status.Phase = v1alpha2.ImageProvisioning + cb.Status(metav1.ConditionFalse).Reason(vicondition.Provisioning).Message("PVC Provisioner not found: create the new one.") + return reconcile.Result{RequeueAfter: time.Second}, nil + } + + vi.Status.Target.PersistentVolumeClaim = pvc.Name + if pvc.Annotations[annotations.AnnPVCPopulationDone] == "true" { + vi.Status.Phase = v1alpha2.ImageReady + cb.Status(metav1.ConditionTrue).Reason(vicondition.Ready).Message("") + vi.Status.Progress = "100%" + ready() + vi.Status.Format = imageformat.StorageFormat(pvc) + return reconcile.Result{RequeueAfter: time.Second}, nil + } + + importPhase, err := pvcImporterPodPhase(ctx, disk, supgen) + if err != nil { + return reconcile.Result{}, err + } + if importPhase == corev1.PodFailed { + vi.Status.Phase = v1alpha2.ImageFailed + cb.Status(metav1.ConditionFalse).Reason(vicondition.ProvisioningFailed).Message("VirtualImage importer Pod failed.") + return reconcile.Result{}, nil + } + + vi.Status.Phase = v1alpha2.ImageProvisioning + if vi.Status.Progress == "" { + vi.Status.Progress = "0%" + } + cb.Status(metav1.ConditionFalse).Reason(vicondition.Provisioning).Message("Import is in the process of provisioning to PVC.") + if importPhase == corev1.PodSucceeded { + return reconcile.Result{RequeueAfter: pvcImportProgressRequeue}, nil + } + if err := refreshPVCImportProgress(ctx, vi, disk, stat, supgen, nil); err != nil { + return reconcile.Result{}, err + } + return reconcile.Result{RequeueAfter: pvcImportProgressRequeue}, nil +} + +func createPVCImportTarget( + ctx context.Context, + vi *v1alpha2.VirtualImage, + supgen supplements.Generator, + size resource.Quantity, + source *service.PVCImportSource, + disk *service.DiskService, +) error { + key := supgen.PersistentVolumeClaim() + switch { + case source == nil: + _, err := disk.PersistentVolumeClaim().CreateBlankTarget(ctx, key, vi.Status.StorageClassName, &size, vi, disk, nil) + return err + case source.Registry != nil: + _, err := disk.PersistentVolumeClaim().CreateTargetFromDVCR(ctx, key, vi.Status.StorageClassName, &size, vi, source.Registry, disk, nil) + return err + case source.PVC != nil: + sourceClaim, err := object.FetchObject(ctx, types.NamespacedName{Name: source.PVC.Name, Namespace: source.PVC.Namespace}, disk.Client(), &corev1.PersistentVolumeClaim{}) + if err != nil { + return fmt.Errorf("fetch source pvc: %w", err) + } + if sourceClaim == nil { + return fmt.Errorf("source pvc %s/%s not found", source.PVC.Namespace, source.PVC.Name) + } + _, err = disk.PersistentVolumeClaim().CreateTargetFromPVC(ctx, key, vi.Status.StorageClassName, &size, vi, sourceClaim, disk, nil) + return err + default: + return nil + } +} + const retryPeriod = 1 func setQuotaExceededPhaseCondition(cb *conditions.ConditionBuilder, phase *v1alpha2.ImagePhase, err error, creationTimestamp metav1.Time) reconcile.Result { @@ -245,10 +494,3 @@ func setQuotaExceededPhaseCondition(cb *conditions.ConditionBuilder, phase *v1al cb.Message(fmt.Sprintf("Quota exceeded: %s; Retry in %d minute.", err, retryPeriod)) return reconcile.Result{RequeueAfter: retryPeriod * time.Minute} } - -const ( - DVRunningConditionType cdiv1.DataVolumeConditionType = "Running" - DVQoutaNotExceededConditionType cdiv1.DataVolumeConditionType = "QuotaNotExceeded" - - DVImagePullFailedReason = "ImagePullFailed" -) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/sources_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/sources_test.go index 76686dc8dd..7fafa23e4b 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/sources_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/sources_test.go @@ -24,11 +24,17 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/service/volumemode" @@ -79,14 +85,6 @@ func (s *sourcesCleanerStub) CleanUpSupplements(context.Context, *v1alpha2.Virtu return s.cleanupSupplementsResult, s.cleanupSupplementsErr } -type sourcesImportCheckerStub struct { - err error -} - -func (s sourcesImportCheckerStub) CheckImportProcess(context.Context, *cdiv1.DataVolume, *corev1.PersistentVolumeClaim) error { - return s.err -} - var _ = Describe("Sources helpers", func() { newVI := func() *v1alpha2.VirtualImage { return &v1alpha2.VirtualImage{ @@ -99,6 +97,31 @@ var _ = Describe("Sources helpers", func() { } } + newScheme := func() *runtime.Scheme { + scheme := runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(netv1.AddToScheme(scheme)).To(Succeed()) + return scheme + } + + newBoundImportPVC := func(name, namespace string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID(name + "-uid"), + Annotations: map[string]string{}, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}, + }, + }, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } + } + Describe("Sources map operations", func() { It("stores handlers, resolves them and detects changes", func() { sources := NewSources() @@ -192,6 +215,64 @@ var _ = Describe("Sources helpers", func() { Expect(IsImageProvisioningFinished(metav1.Condition{Reason: vicondition.Provisioning.String()})).To(BeFalse()) }) + Describe("PVC import resume", func() { + It("waits for populator to import an existing target PVC created from DVCR", func() { + ctx := context.Background() + vi := newVI() + vi.Status.StorageClassName = "sc" + pvc := newBoundImportPVC("target", vi.Namespace) + client := fake.NewClientBuilder().WithScheme(newScheme()).WithObjects(pvc).Build() + disk := service.NewDiskService(client, nil, nil, "vi-controller", service.DiskImporterConfig{Image: "pvc-importer", Verbose: "1"}) + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + source := service.NewPVCRegistryImportSource("docker://registry.example/image", "", "") + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + + result, err := reconcilePVCImportFromDVCR(ctx, vi, &corev1.Pod{}, pvc, source, cb, supgen, nil, disk) + Expect(err).ToNot(HaveOccurred()) + Expect(result.RequeueAfter).ToNot(BeZero()) + + Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageProvisioning)) + Expect(cb.Condition().Reason).To(Equal(vicondition.Provisioning.String())) + }) + + It("waits for populator to import an existing target PVC created from another PVC", func() { + ctx := context.Background() + vi := newVI() + vi.Status.StorageClassName = "sc" + pvc := newBoundImportPVC("target", vi.Namespace) + sourcePVC := newBoundImportPVC("source", vi.Namespace) + client := fake.NewClientBuilder().WithScheme(newScheme()).WithObjects(pvc, sourcePVC).Build() + disk := service.NewDiskService(client, nil, nil, "vi-controller", service.DiskImporterConfig{Image: "pvc-importer", Verbose: "1"}) + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + source := service.NewPVCPVCImportSource(sourcePVC.Name, sourcePVC.Namespace) + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + + result, err := reconcilePVCImportFromReadySource(ctx, vi, pvc, source, resource.MustParse("1Gi"), cb, supgen, nil, disk, func() {}) + Expect(err).ToNot(HaveOccurred()) + Expect(result.RequeueAfter).ToNot(BeZero()) + + Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageProvisioning)) + Expect(cb.Condition().Reason).To(Equal(vicondition.Provisioning.String())) + }) + + It("sets raw format when block PVC import is complete", func() { + ctx := context.Background() + vi := newVI() + pvc := newBoundImportPVC("target", vi.Namespace) + pvc.Spec.VolumeMode = ptr.To(corev1.PersistentVolumeBlock) + pvc.Annotations[annotations.AnnPVCPopulationDone] = "true" + supgen := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID) + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + + result, err := reconcilePVCImportFromReadySource(ctx, vi, pvc, nil, resource.MustParse("200Mi"), cb, supgen, nil, nil, func() {}) + Expect(err).ToNot(HaveOccurred()) + Expect(result.RequeueAfter).ToNot(BeZero()) + + Expect(vi.Status.Phase).To(Equal(v1alpha2.ImageReady)) + Expect(vi.Status.Format).To(Equal(imageformat.FormatRAW)) + }) + }) + DescribeTable( "setPhaseConditionForFinishedImage", func( @@ -216,39 +297,6 @@ var _ = Describe("Sources helpers", func() { Entry("marks image ready when pvc exists", &corev1.PersistentVolumeClaim{}, v1alpha2.ImageReady, metav1.ConditionTrue, vicondition.Ready.String(), ""), ) - DescribeTable( - "setPhaseConditionForPVCProvisioningImage", - func( - dv *cdiv1.DataVolume, - checkerErr error, - expectedPhase v1alpha2.ImagePhase, - expectedStatus metav1.ConditionStatus, - expectedReason string, - expectedMessage string, - expectedErr error, - ) { - vi := newVI() - cb := conditions.NewConditionBuilder(vicondition.ReadyType) - - err := setPhaseConditionForPVCProvisioningImage(context.Background(), dv, vi, nil, cb, sourcesImportCheckerStub{err: checkerErr}) - if expectedErr == nil { - Expect(err).ToNot(HaveOccurred()) - } else { - Expect(err).To(MatchError(expectedErr)) - } - - Expect(vi.Status.Phase).To(Equal(expectedPhase)) - Expect(cb.Condition().Status).To(Equal(expectedStatus)) - Expect(cb.Condition().Reason).To(Equal(expectedReason)) - Expect(cb.Condition().Message).To(Equal(expectedMessage)) - }, - Entry("waits for pvc importer creation when dv is absent", nil, nil, v1alpha2.ImageProvisioning, metav1.ConditionFalse, vicondition.Provisioning.String(), "Waiting for the pvc importer to be created", nil), - Entry("reports provisioning in progress", &cdiv1.DataVolume{}, nil, v1alpha2.ImageProvisioning, metav1.ConditionFalse, vicondition.Provisioning.String(), "Import is in the process of provisioning to PVC.", nil), - Entry("handles data volume not running", &cdiv1.DataVolume{}, service.ErrDataVolumeNotRunning, v1alpha2.ImageProvisioning, metav1.ConditionFalse, vicondition.ProvisioningFailed.String(), "Pvc importer is not running", nil), - Entry("handles missing default storage class", &cdiv1.DataVolume{}, service.ErrDefaultStorageClassNotFound, v1alpha2.ImagePending, metav1.ConditionFalse, vicondition.ProvisioningFailed.String(), "Default StorageClass not found in the cluster: please provide a StorageClass name or set a default StorageClass.", nil), - Entry("returns unexpected error", &cdiv1.DataVolume{}, errors.New("boom"), v1alpha2.ImagePhase(""), metav1.ConditionUnknown, conditions.ReasonUnknown.String(), "", errors.New("boom")), - ) - DescribeTable( "setPhaseConditionFromPodError", func( @@ -305,6 +353,15 @@ var _ = Describe("Sources helpers", func() { Entry("no error", nil, false, nil, v1alpha2.ImagePhase(""), conditions.ReasonUnknown.String(), ""), Entry("storage profile missing", volumemode.ErrStorageProfileNotFound, true, nil, v1alpha2.ImageFailed, vicondition.ProvisioningFailed.String(), "StorageProfile not found in the cluster: Please check a StorageClass name in the cluster or set a default StorageClass."), Entry("default storage class missing", service.ErrDefaultStorageClassNotFound, true, nil, v1alpha2.ImagePending, vicondition.ProvisioningFailed.String(), "Default StorageClass not found in the cluster: please provide a StorageClass name or set a default StorageClass."), + Entry( + "quota exceeded", + errors.New("create dvcr target pvc: exceeded quota: persistentvolumeclaims"), + true, + nil, + v1alpha2.ImageFailed, + vicondition.ProvisioningFailed.String(), + "Quota exceeded: create dvcr target pvc: exceeded quota: persistentvolumeclaims; Retry in 1 minute.", + ), Entry("unexpected error", errors.New("boom"), false, errors.New("boom"), v1alpha2.ImagePhase(""), conditions.ReasonUnknown.String(), ""), ) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pvc_step.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pvc_step.go index e4003bd88e..07394609fe 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pvc_step.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/create_pvc_step.go @@ -19,12 +19,12 @@ package step import ( "context" "fmt" - "strings" vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" k8serrors "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/utils/ptr" @@ -33,6 +33,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/common/annotations" "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/common/provisioner" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" @@ -43,7 +44,11 @@ import ( ) type CreatePersistentVolumeClaimStep struct { - pvc *corev1.PersistentVolumeClaim + pvc *corev1.PersistentVolumeClaim + disk service.VolumeAndAccessModesGetter + pvcSvc interface { + CreateTargetFromVS(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) + } recorder eventrecord.EventRecorderLogger client client.Client cb *conditions.ConditionBuilder @@ -51,12 +56,18 @@ type CreatePersistentVolumeClaimStep struct { func NewCreatePersistentVolumeClaimStep( pvc *corev1.PersistentVolumeClaim, + disk service.VolumeAndAccessModesGetter, + pvcSvc interface { + CreateTargetFromVS(ctx context.Context, key types.NamespacedName, storageClassName string, size *resource.Quantity, owner client.Object, source *vsv1.VolumeSnapshot, modeGetter service.VolumeAndAccessModesGetter, nodePlacement *provisioner.NodePlacement) (corev1.PersistentVolumeClaim, error) + }, recorder eventrecord.EventRecorderLogger, client client.Client, cb *conditions.ConditionBuilder, ) *CreatePersistentVolumeClaimStep { return &CreatePersistentVolumeClaimStep{ pvc: pvc, + disk: disk, + pvcSvc: pvcSvc, recorder: recorder, client: client, cb: cb, @@ -82,6 +93,7 @@ func (s CreatePersistentVolumeClaimStep) Take(ctx context.Context, vi *v1alpha2. if vdSnapshot == nil { vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Progress = "" s.cb. Status(metav1.ConditionFalse). Reason(vicondition.ProvisioningNotStarted). @@ -96,6 +108,7 @@ func (s CreatePersistentVolumeClaimStep) Take(ctx context.Context, vi *v1alpha2. if vdSnapshot.Status.Phase != v1alpha2.VirtualDiskSnapshotPhaseReady || vs == nil || vs.Status == nil || vs.Status.ReadyToUse == nil || !*vs.Status.ReadyToUse { vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Progress = "" s.cb. Status(metav1.ConditionFalse). Reason(vicondition.ProvisioningNotStarted). @@ -118,9 +131,14 @@ func (s CreatePersistentVolumeClaimStep) Take(ctx context.Context, vi *v1alpha2. return &reconcile.Result{}, nil } - pvc := s.buildPVC(vi, vs) + storageClassName := s.storageClassName(vi, vs) + if storageClassName != "" { + vi.Status.StorageClassName = storageClassName + } + size := s.restoreSize(vs) + key := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID).PersistentVolumeClaim() - err = s.client.Create(ctx, pvc) + pvc, err := s.pvcSvc.CreateTargetFromVS(ctx, key, storageClassName, size, vi, vs, s.disk, nil) if err != nil && !k8serrors.IsAlreadyExists(err) { return nil, fmt.Errorf("create pvc: %w", err) } @@ -138,72 +156,22 @@ func (s CreatePersistentVolumeClaimStep) Take(ctx context.Context, vi *v1alpha2. return nil, nil } -func (s CreatePersistentVolumeClaimStep) buildPVC(vi *v1alpha2.VirtualImage, vs *vsv1.VolumeSnapshot) *corev1.PersistentVolumeClaim { - var storageClassName string +func (s CreatePersistentVolumeClaimStep) storageClassName(vi *v1alpha2.VirtualImage, vs *vsv1.VolumeSnapshot) string { if vi.Spec.PersistentVolumeClaim.StorageClass != nil && *vi.Spec.PersistentVolumeClaim.StorageClass != "" { - storageClassName = *vi.Spec.PersistentVolumeClaim.StorageClass - } else { - storageClassName = vs.Annotations[annotations.AnnStorageClassName] - if storageClassName == "" { - storageClassName = vs.Annotations[annotations.AnnStorageClassNameDeprecated] - } - } - volumeMode := vs.Annotations[annotations.AnnVolumeMode] - if volumeMode == "" { - volumeMode = vs.Annotations[annotations.AnnVolumeModeDeprecated] + return *vi.Spec.PersistentVolumeClaim.StorageClass } - accessModesRaw := vs.Annotations[annotations.AnnAccessModes] - if accessModesRaw == "" { - accessModesRaw = vs.Annotations[annotations.AnnAccessModesDeprecated] - } - - accessModesStr := strings.Split(accessModesRaw, ",") - accessModes := make([]corev1.PersistentVolumeAccessMode, 0, len(accessModesStr)) - for _, accessModeStr := range accessModesStr { - accessModes = append(accessModes, corev1.PersistentVolumeAccessMode(accessModeStr)) - } - - spec := corev1.PersistentVolumeClaimSpec{ - AccessModes: accessModes, - DataSource: &corev1.TypedLocalObjectReference{ - APIGroup: ptr.To(vs.GroupVersionKind().Group), - Kind: vs.Kind, - Name: vs.Name, - }, - } - - if storageClassName != "" { - spec.StorageClassName = &storageClassName - vi.Status.StorageClassName = storageClassName - } - - if volumeMode != "" { - spec.VolumeMode = ptr.To(corev1.PersistentVolumeMode(volumeMode)) + storageClassName := vs.Annotations[annotations.AnnStorageClassName] + if storageClassName == "" { + storageClassName = vs.Annotations[annotations.AnnStorageClassNameDeprecated] } + return storageClassName +} +func (s CreatePersistentVolumeClaimStep) restoreSize(vs *vsv1.VolumeSnapshot) *resource.Quantity { if vs.Status != nil && vs.Status.RestoreSize != nil { - spec.Resources = corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: *vs.Status.RestoreSize, - }, - } - } - - pvcKey := supplements.NewGenerator(annotations.VIShortName, vi.Name, vi.Namespace, vi.UID).PersistentVolumeClaim() - - return &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: pvcKey.Name, - Namespace: pvcKey.Namespace, - OwnerReferences: []metav1.OwnerReference{ - service.MakeOwnerReference(vi), - }, - Finalizers: []string{ - v1alpha2.FinalizerVIProtection, - }, - }, - Spec: spec, + return vs.Status.RestoreSize } + return nil } func (s CreatePersistentVolumeClaimStep) validateStorageClassCompatibility(ctx context.Context, vi *v1alpha2.VirtualImage, vdSnapshot *v1alpha2.VirtualDiskSnapshot, vs *vsv1.VolumeSnapshot) error { diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step.go index c85e3f98aa..04ea02836a 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step.go @@ -27,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -117,6 +118,7 @@ func (s ReadyPersistentVolumeClaimStep) Take(ctx context.Context, vi *v1alpha2.V vi.Status.Phase = v1alpha2.ImageReady vi.Status.Progress = "100%" + vi.Status.Format = imageformat.StorageFormat(s.pvc) res := s.pvc.Status.Capacity[corev1.ResourceStorage] intQ, ok := res.AsInt64() diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step_test.go index 2ed4b131ff..2b31a71f09 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/ready_pvc_step_test.go @@ -24,8 +24,10 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/supplements" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -75,5 +77,41 @@ var _ = Describe("ReadyPersistentVolumeClaimStep", func() { Expect(err).ToNot(HaveOccurred()) Expect(vi.Status.Size.Stored).To(Equal("12Gi")) Expect(vi.Status.Size.Unpacked).To(Equal("12Gi")) + Expect(vi.Status.Format).To(Equal(imageformat.FormatQCOW2)) + }) + + It("reports raw format for block PVCs", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "image-pvc"}, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeMode: ptr.To(corev1.PersistentVolumeBlock), + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("200Mi"), + }, + }, + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + Capacity: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("200Mi"), + }, + }, + } + + vi := &v1alpha2.VirtualImage{} + var recorder *eventrecord.EventRecorderLoggerMock + recorder = &eventrecord.EventRecorderLoggerMock{ + EventFunc: noopEvent, + WithLoggingFunc: func(logger eventrecord.InfoLogger) eventrecord.EventRecorderLogger { + return recorder + }, + } + cb := conditions.NewConditionBuilder(vicondition.ReadyType) + step := NewReadyPersistentVolumeClaimStep(pvc, noopReadyPersistentVolumeClaimStepBounder{}, recorder, cb) + + _, err := step.Take(context.Background(), vi) + Expect(err).ToNot(HaveOccurred()) + Expect(vi.Status.Format).To(Equal(imageformat.FormatRAW)) }) }) diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pod_step.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pod_step.go index bc8ee88471..6a3efdb14c 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pod_step.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/step/wait_for_pod_step.go @@ -126,7 +126,7 @@ func (s WaitForPodStep) Take(_ context.Context, vi *v1alpha2.VirtualImage) (*rec Message("Import is in the process of provisioning to DVCR.") vi.Status.Phase = v1alpha2.ImageProvisioning - vi.Status.Progress = s.stat.GetProgress(vi.GetUID(), s.pod, vi.Status.Progress) + vi.Status.Progress = service.CapProgressBelow(s.stat.GetProgress(vi.GetUID(), s.pod, vi.Status.Progress), 100) vi.Status.Target.RegistryURL = s.stat.GetDVCRImageName(s.pod) return &reconcile.Result{RequeueAfter: 2 * time.Second}, nil diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/source/upload.go b/images/virtualization-artifact/pkg/controller/vi/internal/source/upload.go index 2404cccea4..75b9d11b4c 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/source/upload.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/source/upload.go @@ -23,10 +23,8 @@ import ( "time" corev1 "k8s.io/api/core/v1" - storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -95,10 +93,6 @@ func (ds UploadDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.VirtualI if err != nil { return reconcile.Result{}, err } - dv, err := ds.diskService.GetDataVolume(ctx, supgen) - if err != nil { - return reconcile.Result{}, err - } pvc, err := ds.diskService.GetPersistentVolumeClaim(ctx, supgen) if err != nil { return reconcile.Result{}, err @@ -114,38 +108,25 @@ func (ds UploadDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.VirtualI return reconcile.Result{}, err } - var dvQuotaNotExceededCondition *cdiv1.DataVolumeCondition - var dvRunningCondition *cdiv1.DataVolumeCondition - if dv != nil { - dvQuotaNotExceededCondition = service.GetDataVolumeCondition(DVQoutaNotExceededConditionType, dv.Status.Conditions) - dvRunningCondition = service.GetDataVolumeCondition(DVRunningConditionType, dv.Status.Conditions) - } - switch { case IsImageProvisioningFinished(condition): log.Info("Disk provisioning finished: clean up") setPhaseConditionForFinishedImage(pvc, cb, &vi.Status.Phase, supgen) - // Protect Ready Disk and underlying PVC. - err = ds.diskService.Protect(ctx, supgen, vi, nil, pvc) - if err != nil { - return reconcile.Result{}, err - } - // Unprotect upload time supplements to delete them later. err = ds.uploaderService.Unprotect(ctx, supgen, pod, svc, ing) if err != nil { return reconcile.Result{}, err } - err = ds.diskService.Unprotect(ctx, supgen, dv) + err = ds.diskService.Unprotect(ctx, supgen) if err != nil { return reconcile.Result{}, err } return CleanUpSupplements(ctx, vi, ds) - case object.AnyTerminating(pod, svc, ing, dv, pvc): + case object.AnyTerminating(pod, svc, ing, pvc): log.Info("Waiting for supplements to be terminated") case pod == nil || svc == nil || ing == nil: ds.recorder.Event( @@ -180,12 +161,17 @@ func (ds UploadDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.VirtualI case !podutil.IsPodComplete(pod): log.Info("Provisioning to DVCR is in progress", "podPhase", pod.Status.Phase) + uploadStarted := ds.statService.IsUploadStarted(vi.GetUID(), pod) || hasUploadProgress(vi.Status.Progress) err = ds.statService.CheckPod(pod) if err != nil { + if isTransientPodError(err) && uploadStarted { + setUploadProvisioningPhaseCondition(cb, vi) + return reconcile.Result{RequeueAfter: time.Second}, nil + } return reconcile.Result{}, setPhaseConditionFromPodError(cb, vi, err) } - if !ds.statService.IsUploadStarted(vi.GetUID(), pod) { + if !uploadStarted { if isUploaderReady { log.Info("Waiting for the user upload", "pod.phase", pod.Status.Phase) @@ -202,10 +188,10 @@ func (ds UploadDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.VirtualI } else { log.Info("Waiting for the uploader to be ready to process the user's upload", "pod.phase", pod.Status.Phase) - vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Phase = v1alpha2.ImageProvisioning cb. Status(metav1.ConditionFalse). - Reason(vicondition.ProvisioningNotStarted). + Reason(vicondition.Provisioning). Message(fmt.Sprintf("Waiting for the uploader %q to be ready to process the user's upload.", pod.Name)) } @@ -218,133 +204,22 @@ func (ds UploadDataSource) StoreToPVC(ctx context.Context, vi *v1alpha2.VirtualI Reason(vicondition.Provisioning). Message("Import is in the process of provisioning to DVCR.") - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress, service.NewScaleOption(0, 50)) + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress, service.NewScaleOption(0, 50)), 100) vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) err = ds.uploaderService.Protect(ctx, supgen, pod, svc, ing) if err != nil { return reconcile.Result{}, err } - case dv == nil: + default: ds.recorder.Event( vi, corev1.EventTypeNormal, v1alpha2.ReasonDataSourceSyncStarted, "The Upload DataSource import to PVC has started", ) - - err = ds.statService.CheckPod(pod) - if err != nil { - vi.Status.Phase = v1alpha2.ImageFailed - - switch { - case errors.Is(err, service.ErrProvisioningFailed): - ds.recorder.Event(vi, corev1.EventTypeWarning, v1alpha2.ReasonDataSourceDiskProvisioningFailed, "Disk provisioning failed") - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ProvisioningFailed). - Message(service.CapitalizeFirstLetter(err.Error() + ".")) - return reconcile.Result{}, nil - default: - return reconcile.Result{}, err - } - } - - vi.Status.Progress = "50.0%" - vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) - - var diskSize resource.Quantity - diskSize, err = ds.getPVCSize(pod) - if err != nil { - setPhaseConditionToFailed(cb, &vi.Status.Phase, err) - - if errors.Is(err, service.ErrInsufficientPVCSize) { - return reconcile.Result{}, nil - } - - return reconcile.Result{}, err - } - source := ds.getSource(supgen, ds.statService.GetDVCRImageName(pod)) - - var sc *storagev1.StorageClass - sc, err = ds.diskService.GetStorageClass(ctx, vi.Status.StorageClassName) - if err != nil { - return reconcile.Result{}, err - } - err = ds.diskService.StartImmediate(ctx, diskSize, sc, source, vi, supgen) - if updated, err := setPhaseConditionFromStorageError(err, vi, cb); err != nil || updated { - return reconcile.Result{}, err - } - - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC Provisioner not found: create the new one.") - - return reconcile.Result{RequeueAfter: time.Second}, nil - case dvQuotaNotExceededCondition != nil && dvQuotaNotExceededCondition.Status == corev1.ConditionFalse: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.QuotaExceeded). - Message(dvQuotaNotExceededCondition.Message) - return reconcile.Result{}, nil - case dvRunningCondition != nil && dvRunningCondition.Status != corev1.ConditionTrue && dvRunningCondition.Reason == DVImagePullFailedReason: - vi.Status.Phase = v1alpha2.ImagePending - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.ImagePullFailed). - Message(dvRunningCondition.Message) - ds.recorder.Event(vi, corev1.EventTypeWarning, vicondition.ImagePullFailed.String(), dvRunningCondition.Message) - return reconcile.Result{}, nil - case pvc == nil: - vi.Status.Phase = v1alpha2.ImageProvisioning - cb. - Status(metav1.ConditionFalse). - Reason(vicondition.Provisioning). - Message("PVC not found: waiting for creation.") - return reconcile.Result{RequeueAfter: time.Second}, nil - case ds.diskService.IsImportDone(dv, pvc): - log.Info("Import has completed", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - ds.recorder.Event( - vi, - corev1.EventTypeNormal, - v1alpha2.ReasonDataSourceSyncCompleted, - "The Upload DataSource import has completed", - ) - - vi.Status.Phase = v1alpha2.ImageReady - cb. - Status(metav1.ConditionTrue). - Reason(vicondition.Ready). - Message("") - - vi.Status.Progress = "100%" - vi.Status.Size = ds.statService.GetSize(pod) - vi.Status.CDROM = ds.statService.GetCDROM(pod) - vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - - log.Info("Ready", "vi", vi.Name, "progress", vi.Status.Progress, "dv.phase", dv.Status.Phase) - default: - log.Info("Provisioning to PVC is in progress", "dvProgress", dv.Status.Progress, "dvPhase", dv.Status.Phase, "pvcPhase", pvc.Status.Phase) - - vi.Status.Progress = ds.diskService.GetProgress(dv, vi.Status.Progress, service.NewScaleOption(50, 100)) - vi.Status.Target.PersistentVolumeClaim = dv.Status.ClaimName - - err = ds.diskService.Protect(ctx, supgen, vi, dv, pvc) - if err != nil { - return reconcile.Result{}, err - } - - err = setPhaseConditionForPVCProvisioningImage(ctx, dv, vi, pvc, cb, ds.diskService) - if err != nil { - return reconcile.Result{}, err - } - - return reconcile.Result{}, nil + return reconcilePVCImportFromDVCR(ctx, vi, pod, pvc, source, cb, supgen, ds.statService, ds.diskService) } return reconcile.Result{RequeueAfter: time.Second}, nil @@ -399,7 +274,7 @@ func (ds UploadDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.Virtual return CleanUpSupplements(ctx, vi, ds) case object.AnyTerminating(pod, svc, ing): - vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Phase = v1alpha2.ImageProvisioning log.Info("Cleaning up...") case pod == nil || svc == nil || ing == nil: @@ -456,9 +331,13 @@ func (ds UploadDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.Virtual vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) log.Info("Ready", "progress", vi.Status.Progress, "pod.phase", pod.Status.Phase) - case ds.statService.IsUploadStarted(vi.GetUID(), pod): + case ds.statService.IsUploadStarted(vi.GetUID(), pod) || hasUploadProgress(vi.Status.Progress): err = ds.statService.CheckPod(pod) if err != nil { + if isTransientPodError(err) { + setUploadProvisioningPhaseCondition(cb, vi) + return reconcile.Result{RequeueAfter: time.Second}, nil + } return reconcile.Result{}, setPhaseConditionFromPodError(cb, vi, err) } @@ -468,7 +347,7 @@ func (ds UploadDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.Virtual Message("Import is in the process of provisioning to DVCR.") vi.Status.Phase = v1alpha2.ImageProvisioning - vi.Status.Progress = ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress) + vi.Status.Progress = service.CapProgressBelow(ds.statService.GetProgress(vi.GetUID(), pod, vi.Status.Progress), 100) vi.Status.Target.RegistryURL = ds.statService.GetDVCRImageName(pod) vi.Status.DownloadSpeed = ds.statService.GetDownloadSpeed(vi.GetUID(), pod) @@ -495,10 +374,10 @@ func (ds UploadDataSource) StoreToDVCR(ctx context.Context, vi *v1alpha2.Virtual default: cb. Status(metav1.ConditionFalse). - Reason(vicondition.ProvisioningNotStarted). + Reason(vicondition.Provisioning). Message(fmt.Sprintf("Waiting for the uploader %q to be ready to process the user's upload.", pod.Name)) - vi.Status.Phase = v1alpha2.ImagePending + vi.Status.Phase = v1alpha2.ImageProvisioning log.Info("Waiting for the uploader to be ready to process the user's upload", "pod.phase", pod.Status.Phase) } @@ -526,6 +405,27 @@ func (ds UploadDataSource) Validate(_ context.Context, _ *v1alpha2.VirtualImage) return nil } +func setUploadProvisioningPhaseCondition(cb *conditions.ConditionBuilder, vi *v1alpha2.VirtualImage) { + vi.Status.Phase = v1alpha2.ImageProvisioning + cb. + Status(metav1.ConditionFalse). + Reason(vicondition.Provisioning). + Message("Import is in the process of provisioning to DVCR.") +} + +func isTransientPodError(err error) bool { + return errors.Is(err, service.ErrNotInitialized) || errors.Is(err, service.ErrNotScheduled) +} + +func hasUploadProgress(progress string) bool { + switch progress { + case "", "0", "0%", "0.0%", "0.00%": + return false + default: + return true + } +} + func (ds UploadDataSource) getEnvSettings(vi *v1alpha2.VirtualImage, supgen supplements.Generator) *uploader.Settings { var settings uploader.Settings @@ -570,10 +470,10 @@ func (ds UploadDataSource) getPVCSize(pod *corev1.Pod) (resource.Quantity, error return resource.Quantity{}, errors.New("got zero unpacked size from data source") } - return service.GetValidatedPVCSize(&unpackedSize, unpackedSize) + return service.GetValidatedPVCSize(nil, unpackedSize) } -func (ds UploadDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *cdiv1.DataVolumeSource { +func (ds UploadDataSource) getSource(sup supplements.Generator, dvcrSourceImageName string) *service.PVCImportSource { // The image was preloaded from source into dvcr. // We can't use the same data source a second time, but we can set dvcr as the data source. // Use DV name for the Secret with DVCR auth and the ConfigMap with DVCR CA Bundle. @@ -581,11 +481,5 @@ func (ds UploadDataSource) getSource(sup supplements.Generator, dvcrSourceImageN secretName := sup.DVCRAuthSecretForDV().Name certConfigMapName := sup.DVCRCABundleConfigMapForDV().Name - return &cdiv1.DataVolumeSource{ - Registry: &cdiv1.DataVolumeSourceRegistry{ - URL: &url, - SecretRef: &secretName, - CertConfigMap: &certConfigMapName, - }, - } + return service.NewPVCRegistryImportSource(url, secretName, certConfigMapName) } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/storageclass_ready_test.go b/images/virtualization-artifact/pkg/controller/vi/internal/storageclass_ready_test.go index c12ad5fcfc..ac97ae4359 100644 --- a/images/virtualization-artifact/pkg/controller/vi/internal/storageclass_ready_test.go +++ b/images/virtualization-artifact/pkg/controller/vi/internal/storageclass_ready_test.go @@ -176,7 +176,7 @@ func newStorageClassServiceMock(existedStorageClass *string, unsupportedStorageC storageClassServiceMock.ValidateClaimPropertySetsFunc = func(_ *cdiv1.StorageProfile) error { if unsupportedStorageClass { return fmt.Errorf( - "the storage class %q lacks of capabilities to support 'Virtual Images on PVC' function; use StorageClass that supports volume mode 'Block' and access mode 'ReadWriteMany'", + "the storage class %q lacks of capabilities to support 'Virtual Images on PVC' function; use StorageClass that supports volume mode 'Block' or 'Filesystem' and access mode 'ReadWriteOnce' or 'ReadWriteMany'", *existedStorageClass, ) } diff --git a/images/virtualization-artifact/pkg/controller/vi/internal/watcher/datavolume_watcher.go b/images/virtualization-artifact/pkg/controller/vi/internal/watcher/datavolume_watcher.go deleted file mode 100644 index acee82439e..0000000000 --- a/images/virtualization-artifact/pkg/controller/vi/internal/watcher/datavolume_watcher.go +++ /dev/null @@ -1,89 +0,0 @@ -/* -Copyright 2025 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package watcher - -import ( - "fmt" - - cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/event" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/source" - - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/service" - "github.com/deckhouse/virtualization/api/core/v1alpha2" -) - -type DataVolumeWatcher struct{} - -func NewDataVolumeWatcher() *DataVolumeWatcher { - return &DataVolumeWatcher{} -} - -func (w *DataVolumeWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { - if err := ctr.Watch( - source.Kind(mgr.GetCache(), &cdiv1.DataVolume{}, - handler.TypedEnqueueRequestForOwner[*cdiv1.DataVolume]( - mgr.GetScheme(), - mgr.GetRESTMapper(), - &v1alpha2.VirtualImage{}, - handler.OnlyControllerOwner(), - ), - predicate.TypedFuncs[*cdiv1.DataVolume]{ - CreateFunc: func(e event.TypedCreateEvent[*cdiv1.DataVolume]) bool { return false }, - UpdateFunc: func(e event.TypedUpdateEvent[*cdiv1.DataVolume]) bool { - if e.ObjectOld.Status.Progress != e.ObjectNew.Status.Progress { - return true - } - - if e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase { - switch e.ObjectNew.Status.Phase { - case cdiv1.Succeeded, cdiv1.WaitForFirstConsumer, cdiv1.PendingPopulation: - return true - } - } - - if e.ObjectOld.Status.ClaimName != e.ObjectNew.Status.ClaimName { - return true - } - - oldDVQuotaNotExceeded, oldOk := conditions.GetDataVolumeCondition(conditions.DVQoutaNotExceededConditionType, e.ObjectOld.Status.Conditions) - newDVQuotaNotExceeded, newOk := conditions.GetDataVolumeCondition(conditions.DVQoutaNotExceededConditionType, e.ObjectNew.Status.Conditions) - - if !oldOk && newOk { - return true - } - - if oldOk && newOk && oldDVQuotaNotExceeded != newDVQuotaNotExceeded { - return true - } - - dvRunning := service.GetDataVolumeCondition(cdiv1.DataVolumeRunning, e.ObjectNew.Status.Conditions) - return dvRunning != nil && dvRunning.Reason == "Error" - }, - DeleteFunc: func(e event.TypedDeleteEvent[*cdiv1.DataVolume]) bool { return false }, - }, - ), - ); err != nil { - return fmt.Errorf("error setting watch on DV: %w", err) - } - return nil -} diff --git a/images/virtualization-artifact/pkg/controller/vi/vi_controller.go b/images/virtualization-artifact/pkg/controller/vi/vi_controller.go index 7a2adede14..fff81ee255 100644 --- a/images/virtualization-artifact/pkg/controller/vi/vi_controller.go +++ b/images/virtualization-artifact/pkg/controller/vi/vi_controller.go @@ -62,6 +62,7 @@ func NewController( mgr manager.Manager, log *log.Logger, importerImage string, + diskImporterImage string, uploaderImage string, bounderImage string, requirements corev1.ResourceRequirements, @@ -73,7 +74,12 @@ func NewController( importer := service.NewImporterService(dvcr, mgr.GetClient(), importerImage, requirements, PodPullPolicy, PodVerbose, ControllerName, protection) uploader := service.NewUploaderService(dvcr, mgr.GetClient(), uploaderImage, requirements, PodPullPolicy, PodVerbose, ControllerName, protection) bounder := service.NewBounderPodService(dvcr, mgr.GetClient(), bounderImage, requirements, PodPullPolicy, PodVerbose, ControllerName, protection) - disk := service.NewDiskService(mgr.GetClient(), dvcr, protection, ControllerName) + disk := service.NewDiskService(mgr.GetClient(), dvcr, protection, ControllerName, service.DiskImporterConfig{ + Image: diskImporterImage, + ResourceRequirements: requirements, + PullPolicy: PodPullPolicy, + Verbose: PodVerbose, + }) scService := intsvc.NewVirtualImageStorageClassService(service.NewBaseStorageClassService(mgr.GetClient()), storageClassSettings) dvcrService := service.NewDVCRService(mgr.GetClient()) recorder := eventrecord.NewEventRecorderLogger(mgr, ControllerName) diff --git a/images/virtualization-artifact/pkg/controller/vi/vi_reconciler.go b/images/virtualization-artifact/pkg/controller/vi/vi_reconciler.go index 739538b649..8f8741e09e 100644 --- a/images/virtualization-artifact/pkg/controller/vi/vi_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/vi/vi_reconciler.go @@ -116,7 +116,6 @@ func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr watcher.NewStorageClassWatcher(mgrClient), watcher.NewVirtualMachineWatcher(mgrClient), watcher.NewVirtualDiskSnapshotWatcher(mgrClient), - watcher.NewDataVolumeWatcher(), watcher.NewPersistentVolumeClaimWatcher(), watcher.NewVirtualDiskWatcher(mgrClient), postponeimporter.NewWatcher[*v1alpha2.VirtualImage](mgrClient, logger), diff --git a/images/virtualization-artifact/pkg/controller/vi/vi_webhook.go b/images/virtualization-artifact/pkg/controller/vi/vi_webhook.go index bc0514d692..60b0b1dd23 100644 --- a/images/virtualization-artifact/pkg/controller/vi/vi_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vi/vi_webhook.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/common/storageclass" "github.com/deckhouse/virtualization-controller/pkg/common/validate" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" intsvc "github.com/deckhouse/virtualization-controller/pkg/controller/vi/internal/service" @@ -65,6 +66,10 @@ func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (adm return nil, fmt.Errorf("the VirtualImage name %q is too long: it must be no more than %d characters", vi.Name, validate.MaxVirtualImageNameLen) } + if err := v.validateSourceStorageClassProvisionerCompatibility(ctx, vi); err != nil { + return nil, err + } + if vi.Spec.Storage == v1alpha2.StorageKubernetes { warnings := admission.Warnings{ fmt.Sprintf("Using the `%s` storage type is deprecated. It is recommended to use `%s` instead.", @@ -204,6 +209,92 @@ func (v *Validator) ValidateDelete(_ context.Context, _ runtime.Object) (admissi return nil, nil } +// validateSourceStorageClassProvisionerCompatibility forbids creating a VirtualImage +// on a PVC from an object reference (VirtualDisk, VirtualImage, VirtualDiskSnapshot) +// whose storage is backed by a different CSI driver than the target storage class. +// Cross-CSI-driver provisioning is not supported. +func (v *Validator) validateSourceStorageClassProvisionerCompatibility(ctx context.Context, vi *v1alpha2.VirtualImage) error { + // Only PVC-backed targets have a storage class provisioner to compare against. + if vi.Spec.Storage != v1alpha2.StoragePersistentVolumeClaim && vi.Spec.Storage != v1alpha2.StorageKubernetes { + return nil + } + + if vi.Spec.DataSource.Type != v1alpha2.DataSourceTypeObjectRef || vi.Spec.DataSource.ObjectRef == nil { + return nil + } + + ref := vi.Spec.DataSource.ObjectRef + + var ( + sourceProvisioner string + determinable bool + err error + ) + switch ref.Kind { + case v1alpha2.VirtualImageObjectRefKindVirtualDisk: + sourceProvisioner, determinable, err = storageclass.ProvisionerOfVirtualDisk(ctx, v.client, vi.Namespace, ref.Name) + case v1alpha2.VirtualImageObjectRefKindVirtualImage: + sourceProvisioner, determinable, err = storageclass.ProvisionerOfVirtualImage(ctx, v.client, vi.Namespace, ref.Name) + case v1alpha2.VirtualImageObjectRefKindVirtualDiskSnapshot: + sourceProvisioner, determinable, err = storageclass.ProvisionerOfVirtualDiskSnapshot(ctx, v.client, vi.Namespace, ref.Name) + default: + return nil + } + if err != nil { + return err + } + if !determinable { + return nil + } + + targetSCName := v.resolveTargetStorageClassName(ctx, vi) + if targetSCName == "" { + return nil + } + + targetProvisioner, err := storageclass.ProvisionerOf(ctx, v.client, targetSCName) + if err != nil { + return err + } + if targetProvisioner == "" { + return nil + } + + if targetProvisioner != sourceProvisioner { + return fmt.Errorf( + "virtual image storage class %q provisioner %q does not match the source %s %q provisioner %q: "+ + "creating an image from a source on a different CSI driver is not supported", + targetSCName, targetProvisioner, ref.Kind, ref.Name, sourceProvisioner, + ) + } + + return nil +} + +// resolveTargetStorageClassName resolves the storage class name a PVC-backed +// VirtualImage will use: the explicit spec value, otherwise the module default, +// otherwise the cluster default. Returns an empty name when it cannot be resolved. +func (v *Validator) resolveTargetStorageClassName(ctx context.Context, vi *v1alpha2.VirtualImage) string { + if vi.Spec.PersistentVolumeClaim.StorageClass != nil && *vi.Spec.PersistentVolumeClaim.StorageClass != "" { + return *vi.Spec.PersistentVolumeClaim.StorageClass + } + + if moduleSC, err := v.scService.GetModuleStorageClass(ctx); err == nil && moduleSC != nil { + return moduleSC.Name + } + + defaultSC, err := v.scService.GetDefaultStorageClass(ctx) + if err != nil { + // Cannot determine the target storage class; skip the cross-CSI check. + return "" + } + if defaultSC != nil { + return defaultSC.Name + } + + return "" +} + func (v *Validator) validateDefaultStorageClass(ctx context.Context) error { var sc *storagev1.StorageClass mcDefaultStorageClass, err := v.scService.GetModuleStorageClass(ctx) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/util.go b/images/virtualization-artifact/pkg/controller/vm/internal/util.go index 9bd9ef9639..e30176d587 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/util.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/util.go @@ -110,8 +110,7 @@ var mapPhases = map[virtv1.VirtualMachinePrintableStatus]PhaseGetter{ return v1alpha2.MachineStopped }, - // VirtualMachineStatusProvisioning indicates that cluster resources associated with the virtual machine - // (e.g., DataVolumes) are being provisioned and prepared. + // VirtualMachineStatusProvisioning indicates that cluster resources associated with the virtual machine are being provisioned and prepared. virtv1.VirtualMachineStatusProvisioning: func(_ *v1alpha2.VirtualMachine, _ *virtv1.VirtualMachine) v1alpha2.MachinePhase { return v1alpha2.MachineStarting }, @@ -137,8 +136,7 @@ var mapPhases = map[virtv1.VirtualMachinePrintableStatus]PhaseGetter{ virtv1.VirtualMachineStatusStopping: func(_ *v1alpha2.VirtualMachine, _ *virtv1.VirtualMachine) v1alpha2.MachinePhase { return v1alpha2.MachineStopping }, - // VirtualMachineStatusTerminating indicates that the virtual machine is in the process of deletion, - // as well as its associated resources (VirtualMachineInstance, DataVolumes, …). + // VirtualMachineStatusTerminating indicates that the virtual machine and its associated resources are being deleted. virtv1.VirtualMachineStatusTerminating: func(_ *v1alpha2.VirtualMachine, _ *virtv1.VirtualMachine) v1alpha2.MachinePhase { return v1alpha2.MachineTerminating }, @@ -175,8 +173,7 @@ var mapPhases = map[virtv1.VirtualMachinePrintableStatus]PhaseGetter{ virtv1.VirtualMachineStatusPvcNotFound: func(_ *v1alpha2.VirtualMachine, _ *virtv1.VirtualMachine) v1alpha2.MachinePhase { return v1alpha2.MachinePending }, - // VirtualMachineStatusDataVolumeError indicates that an error has been reported by one of the DataVolumes - // referenced by the virtual machines. + // KubeVirt keeps this status reason for compatibility with older volume flows. virtv1.VirtualMachineStatusDataVolumeError: func(_ *v1alpha2.VirtualMachine, _ *virtv1.VirtualMachine) v1alpha2.MachinePhase { return v1alpha2.MachinePending }, diff --git a/images/virtualization-artifact/pkg/dvcr/dvcr.go b/images/virtualization-artifact/pkg/dvcr/dvcr.go index 561fc75980..0fb975bfd1 100644 --- a/images/virtualization-artifact/pkg/dvcr/dvcr.go +++ b/images/virtualization-artifact/pkg/dvcr/dvcr.go @@ -24,6 +24,8 @@ import ( ) type Settings struct { + // ControllerNamespace is the namespace where the virtualization-controller runs. + ControllerNamespace string // AuthSecret is a name of the Secret with docker authentication. AuthSecret string // AuthSecretNamespace is a namespace for the AuthSecret. diff --git a/monitoring/prometheus-rules/internal-virtualization-cdi-apiservier.yaml b/monitoring/prometheus-rules/internal-virtualization-cdi-apiservier.yaml deleted file mode 100644 index b93c7fdb2c..0000000000 --- a/monitoring/prometheus-rules/internal-virtualization-cdi-apiservier.yaml +++ /dev/null @@ -1,36 +0,0 @@ -- name: kubernetes.internal.virtualization.cdi_apiserver_state - rules: - - alert: D8InternalVirtualizationCDIAPIServerPodIsNotReady - expr: min by (pod) (kube_pod_status_ready{condition="true", namespace="d8-virtualization", pod=~"cdi-apiserver-.*"}) != 1 - labels: - severity_level: "6" - tier: cluster - for: 10m - annotations: - plk_protocol_version: "1" - plk_markup_format: "markdown" - plk_labels_as_annotations: "pod" - plk_create_group_if_not_exists__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - plk_grouped_by__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - summary: The cdi-apiserver Pod is NOT Ready. - description: | - The recommended course of action: - 1. Retrieve details of the Deployment: `kubectl -n d8-virtualization describe deploy cdi-apiserver` - 2. View the status of the Pod and try to figure out why it is not running: `kubectl -n d8-virtualization describe pod -l cdi.internal.virtualization.deckhouse.io=cdi-apiserver` - - - alert: D8InternalVirtualizationCDIAPIServerPodIsNotRunning - expr: absent(kube_pod_status_phase{namespace="d8-virtualization",phase="Running",pod=~"cdi-apiserver-.*"}) - labels: - severity_level: "6" - tier: cluster - for: 10m - annotations: - plk_protocol_version: "1" - plk_markup_format: "markdown" - plk_create_group_if_not_exists__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - plk_grouped_by__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - summary: The cdi-apiserver Pod is NOT Running. - description: | - The recommended course of action: - 1. Retrieve details of the Deployment: `kubectl -n d8-virtualization describe deploy cdi-apiserver` - 2. View the status of the Pod and try to figure out why it is not running: `kubectl -n d8-virtualization describe pod -l cdi.internal.virtualization.deckhouse.io=cdi-apiserver` diff --git a/monitoring/prometheus-rules/internal-virtualization-cdi-deployment.yaml b/monitoring/prometheus-rules/internal-virtualization-cdi-deployment.yaml deleted file mode 100644 index 671ac2ec33..0000000000 --- a/monitoring/prometheus-rules/internal-virtualization-cdi-deployment.yaml +++ /dev/null @@ -1,36 +0,0 @@ -- name: kubernetes.internal.virtualization.cdi_deployment_state - rules: - - alert: D8InternalVirtualizationCDIDeploymentPodIsNotReady - expr: min by (pod) (kube_pod_status_ready{condition="true", namespace="d8-virtualization", pod=~"cdi-deployment-.*"}) != 1 - labels: - severity_level: "6" - tier: cluster - for: 10m - annotations: - plk_protocol_version: "1" - plk_markup_format: "markdown" - plk_labels_as_annotations: "pod" - plk_create_group_if_not_exists__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - plk_grouped_by__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - summary: The cdi-deployment Pod is NOT Ready. - description: | - The recommended course of action: - 1. Retrieve details of the Deployment: `kubectl -n d8-virtualization describe deploy cdi-deployment` - 2. View the status of the Pod and try to figure out why it is not running: `kubectl -n d8-virtualization describe pod -l app=containerized-data-importer` - - - alert: D8InternalVirtualizationCDIDeploymentPodIsNotRunning - expr: absent(kube_pod_status_phase{namespace="d8-virtualization",phase="Running",pod=~"cdi-deployment-.*"}) - labels: - severity_level: "6" - tier: cluster - for: 10m - annotations: - plk_protocol_version: "1" - plk_markup_format: "markdown" - plk_create_group_if_not_exists__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - plk_grouped_by__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - summary: The cdi-deployment Pod is NOT Running. - description: | - The recommended course of action: - 1. Retrieve details of the Deployment: `kubectl -n d8-virtualization describe deploy cdi-deployment` - 2. View the status of the Pod and try to figure out why it is not running: `kubectl -n d8-virtualization describe pod -l app=containerized-data-importer` diff --git a/monitoring/prometheus-rules/internal-virtualization-cdi-operator.yaml b/monitoring/prometheus-rules/internal-virtualization-cdi-operator.yaml deleted file mode 100644 index eb5f20decc..0000000000 --- a/monitoring/prometheus-rules/internal-virtualization-cdi-operator.yaml +++ /dev/null @@ -1,36 +0,0 @@ -- name: kubernetes.internal.virtualization.cdi_operator_state - rules: - - alert: D8InternalVirtualizationCDIOperatorPodIsNotReady - expr: min by (pod) (kube_pod_status_ready{condition="true", namespace="d8-virtualization", pod=~"cdi-operator-.*"}) != 1 - labels: - severity_level: "6" - tier: cluster - for: 10m - annotations: - plk_protocol_version: "1" - plk_markup_format: "markdown" - plk_labels_as_annotations: "pod" - plk_create_group_if_not_exists__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - plk_grouped_by__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - summary: The cdi-operator Pod is NOT Ready. - description: | - The recommended course of action: - 1. Retrieve details of the Deployment: `kubectl -n d8-virtualization describe deploy cdi-operator` - 2. View the status of the Pod and try to figure out why it is not running: `kubectl -n d8-virtualization describe pod -l app=cdi-operator` - - - alert: D8InternalVirtualizationCDIOperatorPodIsNotRunning - expr: absent(kube_pod_status_phase{namespace="d8-virtualization",phase="Running",pod=~"cdi-operator-.*"}) - labels: - severity_level: "6" - tier: cluster - for: 10m - annotations: - plk_protocol_version: "1" - plk_markup_format: "markdown" - plk_create_group_if_not_exists__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - plk_grouped_by__d8_internal_virtualization_cdi_health: "D8InternalVirtualizationCDIHealth,tier=~tier,prometheus=deckhouse,kubernetes=~kubernetes" - summary: The cdi-operator Pod is NOT Running. - description: | - The recommended course of action: - 1. Retrieve details of the Deployment: `kubectl -n d8-virtualization describe deploy cdi-operator` - 2. View the status of the Pod and try to figure out why it is not running: `kubectl -n d8-virtualization describe pod -l app=cdi-operator` diff --git a/src/kubevirt-rules/kubevirt_rules.go b/src/kubevirt-rules/kubevirt_rules.go index a4f2520993..730e13804b 100644 --- a/src/kubevirt-rules/kubevirt_rules.go +++ b/src/kubevirt-rules/kubevirt_rules.go @@ -30,27 +30,17 @@ var KubevirtRewriteRules = &rewriter.RewriteRules{ KindPrefix: "InternalVirtualization", // VirtualMachine -> InternalVirtualizationVirtualMachine ResourceTypePrefix: "internalvirtualization", // virtualmachines -> internalvirtualizationvirtualmachines ShortNamePrefix: "intvirt", // kubectl get intvirtvm - Categories: []string{"intvirt"}, // kubectl get intvirt to see all KubeVirt and CDI resources. + Categories: []string{"intvirt"}, // kubectl get intvirt to see all internal virtualization resources. Rules: KubevirtAPIGroupsRules, Webhooks: KubevirtWebhooks, Labels: rewriter.MetadataReplace{ Names: []rewriter.MetadataReplaceRule{ - {Original: "cdi.kubevirt.io", Renamed: "cdi." + internalPrefix}, {Original: "kubevirt.io", Renamed: "kubevirt." + internalPrefix}, {Original: "operator.kubevirt.io", Renamed: "operator.kubevirt." + internalPrefix}, {Original: "prometheus.kubevirt.io", Renamed: "prometheus.kubevirt." + internalPrefix}, - {Original: "prometheus.cdi.kubevirt.io", Renamed: "prometheus.cdi." + internalPrefix}, // Special cases. {Original: "node-labeller.kubevirt.io/skip-node", Renamed: "node-labeller." + rootPrefix + "/skip-node"}, {Original: "node-labeller.kubevirt.io/obsolete-host-model", Renamed: "node-labeller." + internalPrefix + "/obsolete-host-model"}, - { - Original: "app.kubernetes.io/managed-by", OriginalValue: "cdi-operator", - Renamed: "app.kubernetes.io/managed-by", RenamedValue: "cdi-operator-internal-virtualization", - }, - { - Original: "app.kubernetes.io/managed-by", OriginalValue: "cdi-controller", - Renamed: "app.kubernetes.io/managed-by", RenamedValue: "cdi-controller-internal-virtualization", - }, { Original: "app.kubernetes.io/managed-by", OriginalValue: "virt-operator", Renamed: "app.kubernetes.io/managed-by", RenamedValue: "virt-operator-internal-virtualization", @@ -61,11 +51,6 @@ var KubevirtRewriteRules = &rewriter.RewriteRules{ }, }, Prefixes: []rewriter.MetadataReplaceRule{ - // CDI related labels. - {Original: "cdi.kubevirt.io", Renamed: "cdi." + internalPrefix}, - {Original: "operator.cdi.kubevirt.io", Renamed: "operator.cdi." + internalPrefix}, - {Original: "prometheus.cdi.kubevirt.io", Renamed: "prometheus.cdi." + internalPrefix}, - {Original: "upload.cdi.kubevirt.io", Renamed: "upload.cdi." + internalPrefix}, // KubeVirt related labels. {Original: "kubevirt.io", Renamed: "kubevirt." + internalPrefix}, {Original: "prometheus.kubevirt.io", Renamed: "prometheus.kubevirt." + internalPrefix}, @@ -87,9 +72,6 @@ var KubevirtRewriteRules = &rewriter.RewriteRules{ }, Annotations: rewriter.MetadataReplace{ Prefixes: []rewriter.MetadataReplaceRule{ - // CDI related annotations. - {Original: "cdi.kubevirt.io", Renamed: "cdi." + internalPrefix}, - {Original: "operator.cdi.kubevirt.io", Renamed: "operator.cdi." + internalPrefix}, // KubeVirt related annotations. {Original: "kubevirt.io", Renamed: "kubevirt." + internalPrefix}, {Original: "certificates.kubevirt.io", Renamed: "certificates.kubevirt." + internalPrefix}, @@ -98,32 +80,11 @@ var KubevirtRewriteRules = &rewriter.RewriteRules{ Finalizers: rewriter.MetadataReplace{ Prefixes: []rewriter.MetadataReplaceRule{ {Original: "kubevirt.io", Renamed: "kubevirt." + internalPrefix}, - {Original: "operator.cdi.kubevirt.io", Renamed: "operator.cdi." + internalPrefix}, - }, - }, - Excludes: []rewriter.ExcludeRule{ - rewriter.ExcludeRule{ - Kinds: []string{ - "PersistentVolumeClaim", - "PersistentVolume", - "Pod", - }, - MatchLabels: map[string]string{ - "app.kubernetes.io/managed-by": "cdi-controller", - }, - }, - rewriter.ExcludeRule{ - Kinds: []string{ - "CDI", - }, - MatchNames: []string{ - "cdi", - }, }, }, } -// TODO create generator in golang to produce below rules from Kubevirt and CDI sources so proxy can work with future versions. +// TODO create generator in golang to produce below rules from KubeVirt sources so proxy can work with future versions. var KubevirtAPIGroupsRules = map[string]rewriter.APIGroupRule{ "cdi.kubevirt.io": { @@ -134,72 +95,6 @@ var KubevirtAPIGroupsRules = map[string]rewriter.APIGroupRule{ Renamed: "cdi." + internalPrefix, }, ResourceRules: map[string]rewriter.ResourceRule{ - // cdiconfigs.cdi.kubevirt.io - "cdiconfigs": { - Kind: "CDIConfig", - ListKind: "CDIConfigList", - Plural: "cdiconfigs", - Singular: "cdiconfig", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{}, - ShortNames: []string{}, - }, - // cdis.cdi.kubevirt.io - "cdis": { - Kind: "CDI", - ListKind: "CDIList", - Plural: "cdis", - Singular: "cdi", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{}, - ShortNames: []string{"cdi", "cdis"}, - }, - // dataimportcrons.cdi.kubevirt.io - "dataimportcrons": { - Kind: "DataImportCron", - ListKind: "DataImportCronList", - Plural: "dataimportcrons", - Singular: "dataimportcron", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{"all"}, - ShortNames: []string{"dic", "dics"}, - }, - // datasources.cdi.kubevirt.io - "datasources": { - Kind: "DataSource", - ListKind: "DataSourceList", - Plural: "datasources", - Singular: "datasource", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{"all"}, - ShortNames: []string{"das"}, - }, - // datavolumes.cdi.kubevirt.io - "datavolumes": { - Kind: "DataVolume", - ListKind: "DataVolumeList", - Plural: "datavolumes", - Singular: "datavolume", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{"all"}, - ShortNames: []string{"dv", "dvs"}, - }, - // objecttransfers.cdi.kubevirt.io - "objecttransfers": { - Kind: "ObjectTransfer", - ListKind: "ObjectTransferList", - Plural: "objecttransfers", - Singular: "objecttransfer", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{}, - ShortNames: []string{"ot", "ots"}, - }, // storageprofiles.cdi.kubevirt.io "storageprofiles": { Kind: "StorageProfile", @@ -211,69 +106,6 @@ var KubevirtAPIGroupsRules = map[string]rewriter.APIGroupRule{ Categories: []string{}, ShortNames: []string{}, }, - // volumeclonesources.cdi.kubevirt.io - "volumeclonesources": { - Kind: "VolumeCloneSource", - ListKind: "VolumeCloneSourceList", - Plural: "volumeclonesources", - Singular: "volumeclonesource", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{}, - ShortNames: []string{}, - }, - // volumeimportsources.cdi.kubevirt.io - "volumeimportsources": { - Kind: "VolumeImportSource", - ListKind: "VolumeImportSourceList", - Plural: "volumeimportsources", - Singular: "volumeimportsource", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{}, - ShortNames: []string{}, - }, - // volumeuploadsources.cdi.kubevirt.io - "volumeuploadsources": { - Kind: "VolumeUploadSource", - ListKind: "VolumeUploadSourceList", - Plural: "volumeuploadsources", - Singular: "volumeuploadsource", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Categories: []string{}, - ShortNames: []string{}, - }, - }, - }, - "forklift.cdi.kubevirt.io": { - GroupRule: rewriter.GroupRule{ - Group: "forklift.cdi.kubevirt.io", - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - Renamed: "forklift.cdi." + internalPrefix, - }, - ResourceRules: map[string]rewriter.ResourceRule{ - // openstackvolumepopulators.forklift.cdi.kubevirt.io - "openstackvolumepopulators": { - Kind: "OpenstackVolumePopulator", - ListKind: "OpenstackVolumePopulatorList", - Plural: "openstackvolumepopulators", - Singular: "openstackvolumepopulator", - ShortNames: []string{"osvp", "osvps"}, - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - }, - // ovirtvolumepopulators.forklift.cdi.kubevirt.io - "ovirtvolumepopulators": { - Kind: "OvirtVolumePopulator", - ListKind: "OvirtVolumePopulatorList", - Plural: "ovirtvolumepopulators", - Singular: "ovirtvolumepopulator", - ShortNames: []string{"ovvp", "ovvps"}, - Versions: []string{"v1beta1"}, - PreferredVersion: "v1beta1", - }, }, }, "kubevirt.io": { @@ -281,7 +113,7 @@ var KubevirtAPIGroupsRules = map[string]rewriter.APIGroupRule{ Group: "kubevirt.io", Versions: []string{"v1", "v1alpha3"}, PreferredVersion: "v1", - Renamed: "internal.virtualization.deckhouse.io", + Renamed: internalPrefix, }, ResourceRules: map[string]rewriter.ResourceRule{ // kubevirts.kubevirt.io @@ -536,41 +368,6 @@ var KubevirtAPIGroupsRules = map[string]rewriter.APIGroupRule{ } var KubevirtWebhooks = map[string]rewriter.WebhookRule{ - // CDI webhooks. - // Run this in original CDI installation: - // kubectl get validatingwebhookconfiguration,mutatingwebhookconfiguration -l cdi.kubevirt.io -o json | jq '.items[] | .webhooks[] | {"path": .clientConfig.service.path, "group": (.rules[]|.apiGroups|join(",")), "resource": (.rules[]|.resources|join(",")) } | "\""+.path +"\": {\nPath: \"" + .path + "\",\nGroup: \"" + .group + "\",\nResource: \"" + .resource + "\",\n}," ' -r - // TODO create generator in golang to extract these rules from resource definitions in the cdi-operator package. - "/datavolume-mutate": { - Path: "/datavolume-mutate", - Group: "cdi.kubevirt.io", - Resource: "datavolumes", - }, - "/dataimportcron-validate": { - Path: "/dataimportcron-validate", - Group: "cdi.kubevirt.io", - Resource: "dataimportcrons", - }, - "/datavolume-validate": { - Path: "/datavolume-validate", - Group: "cdi.kubevirt.io", - Resource: "datavolumes", - }, - "/cdi-validate": { - Path: "/cdi-validate", - Group: "cdi.kubevirt.io", - Resource: "cdis", - }, - "/objecttransfer-validate": { - Path: "/objecttransfer-validate", - Group: "cdi.kubevirt.io", - Resource: "objecttransfers", - }, - "/populator-validate": { - Path: "/populator-validate", - Group: "cdi.kubevirt.io", - Resource: "volumeimportsources", // Also, volumeuploadsources. This field for logging only. - }, - // Kubevirt webhooks. // Run this in original Kubevirt installation: // kubectl get validatingwebhookconfiguration,mutatingwebhookconfiguration -l kubevirt.io -o json | jq '.items[] | .webhooks[] | {"path": .clientConfig.service.path, "group": (.rules[]|.apiGroups|join(",")), "resource": (.rules[]|.resources|join(",")) } | "\""+.path +"\": {\nPath: \"" + .path + "\",\nGroup: \"" + .group + "\",\nResource: \"" + .resource + "\",\n}," ' diff --git a/templates/admission-policy.yaml b/templates/admission-policy.yaml index 029d7ac860..070ac4b3f3 100644 --- a/templates/admission-policy.yaml +++ b/templates/admission-policy.yaml @@ -19,10 +19,8 @@ spec: matchConstraints: resourceRules: - apiGroups: - - "cdi.internal.virtualization.deckhouse.io" - "clone.internal.virtualization.deckhouse.io" - "export.internal.virtualization.deckhouse.io" - - "forklift.cdi.internal.virtualization.deckhouse.io" - "instancetype.internal.virtualization.deckhouse.io" - "internal.virtualization.deckhouse.io" - "pool.internal.virtualization.deckhouse.io" @@ -40,11 +38,6 @@ spec: request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || request.userInfo.username.startsWith("system:serviceaccount:d8-system:") || request.userInfo.username in [ - "system:serviceaccount:d8-virtualization:cdi-apiserver", - "system:serviceaccount:d8-virtualization:cdi-cronjob", - "system:serviceaccount:d8-virtualization:cdi-operator", - "system:serviceaccount:d8-virtualization:cdi-sa", - "system:serviceaccount:d8-virtualization:cdi-uploadproxy", "system:serviceaccount:d8-virtualization:kubevirt-internal-virtualization-apiserver", "system:serviceaccount:d8-virtualization:kubevirt-internal-virtualization-controller", "system:serviceaccount:d8-virtualization:kubevirt-internal-virtualization-exportproxy", diff --git a/templates/cdi/cdi-apiserver/vpa.yaml b/templates/cdi/cdi-apiserver/vpa.yaml deleted file mode 100644 index 4a0c3adcd3..0000000000 --- a/templates/cdi/cdi-apiserver/vpa.yaml +++ /dev/null @@ -1,26 +0,0 @@ -{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} ---- -apiVersion: autoscaling.k8s.io/v1 -kind: VerticalPodAutoscaler -metadata: - name: cdi-apiserver - namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "cdi-apiserver" )) | nindent 2 }} -spec: - targetRef: - apiVersion: "apps/v1" - kind: Deployment - name: cdi-apiserver - updatePolicy: - updateMode: {{ include "vpa.policyUpdateMode" . }} - resourcePolicy: - containerPolicies: - {{- include "kube_api_rewriter.vpa_container_policy" . | nindent 4 }} - - containerName: cdi-apiserver - minAllowed: - cpu: 10m - memory: 20Mi - maxAllowed: - cpu: 100m - memory: 40Mi -{{- end }} diff --git a/templates/cdi/cdi-deployment/rbac-for-us.yaml b/templates/cdi/cdi-deployment/rbac-for-us.yaml deleted file mode 100644 index 18fcb00d18..0000000000 --- a/templates/cdi/cdi-deployment/rbac-for-us.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: d8:{{ .Chart.Name }}:cdi-deployment-rbac-proxy - {{- include "helm_lib_module_labels" (list . (dict "app" "cdi-deployment")) | nindent 2 }} -subjects: - - kind: ServiceAccount - name: cdi-sa - namespace: d8-{{ .Chart.Name }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: d8:rbac-proxy diff --git a/templates/cdi/cdi-deployment/vpa.yaml b/templates/cdi/cdi-deployment/vpa.yaml deleted file mode 100644 index ed1f167b88..0000000000 --- a/templates/cdi/cdi-deployment/vpa.yaml +++ /dev/null @@ -1,26 +0,0 @@ -{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} ---- -apiVersion: autoscaling.k8s.io/v1 -kind: VerticalPodAutoscaler -metadata: - name: cdi-deployment - namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "cdi-deployment" )) | nindent 2 }} -spec: - targetRef: - apiVersion: "apps/v1" - kind: Deployment - name: cdi-deployment - updatePolicy: - updateMode: {{ include "vpa.policyUpdateMode" . }} - resourcePolicy: - containerPolicies: - {{- include "kube_api_rewriter.vpa_container_policy" . | nindent 4 }} - - containerName: cdi-deployment - minAllowed: - cpu: 10m - memory: 30Mi - maxAllowed: - cpu: 100m - memory: 60Mi -{{- end }} diff --git a/templates/cdi/cdi-operator/configmap.yaml b/templates/cdi/cdi-operator/configmap.yaml deleted file mode 100644 index 354fea7412..0000000000 --- a/templates/cdi/cdi-operator/configmap.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: cdi-operator-leader-election-helper - namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list .) | nindent 2 }} diff --git a/templates/cdi/cdi-operator/deployment.yaml b/templates/cdi/cdi-operator/deployment.yaml deleted file mode 100644 index 40e2dad3f3..0000000000 --- a/templates/cdi/cdi-operator/deployment.yaml +++ /dev/null @@ -1,143 +0,0 @@ -{{- $priorityClassName := include "priorityClassName" . }} - -{{- define "cdi_images" -}} -- name: CONTROLLER_IMAGE - value: {{ include "helm_lib_module_image" (list . "cdiController") }} -- name: IMPORTER_IMAGE - value: {{ include "helm_lib_module_image" (list . "cdiImporter") }} -- name: CLONER_IMAGE - value: {{ include "helm_lib_module_image" (list . "cdiCloner") }} -- name: OVIRT_POPULATOR_IMAGE - value: {{ include "helm_lib_module_image" (list . "cdiImporter") }} -- name: APISERVER_IMAGE - value: {{ include "helm_lib_module_image" (list . "cdiApiserver") }} -{{- end }} - -{{- define "cdi_operator_resources" }} -cpu: 15m -memory: 15Mi -{{- end }} - -{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} ---- -apiVersion: autoscaling.k8s.io/v1 -kind: VerticalPodAutoscaler -metadata: - name: cdi-operator - namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "cdi-operator" )) | nindent 2 }} -spec: - targetRef: - apiVersion: "apps/v1" - kind: Deployment - name: cdi-operator - updatePolicy: - updateMode: {{ include "vpa.policyUpdateMode" . }} - resourcePolicy: - containerPolicies: - {{- include "kube_api_rewriter.vpa_container_policy" . | nindent 4 }} - {{- include "kube_rbac_proxy.vpa_container_policy" . | nindent 4 }} - - containerName: cdi-operator - minAllowed: - {{- include "cdi_operator_resources" . | nindent 8 }} - maxAllowed: - cpu: 20m - memory: 30Mi -{{- end }} ---- -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: cdi-operator - namespace: d8-{{ .Chart.Name }} - {{- include "helm_lib_module_labels" (list . (dict "app" "cdi-operator" )) | nindent 2 }} -spec: - minAvailable: {{ include "helm_lib_is_ha_to_value" (list . 1 0) }} - selector: - matchLabels: - app: cdi-operator ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: cdi-operator - namespace: d8-{{ .Chart.Name }} -spec: - {{- include "helm_lib_deployment_strategy_and_replicas_for_ha" . | nindent 2 }} - revisionHistoryLimit: 2 - selector: - matchLabels: - app: cdi-operator - template: - metadata: - labels: - app: cdi-operator - annotations: - kubectl.kubernetes.io/default-container: cdi-operator - spec: - {{- include "helm_lib_pod_anti_affinity_for_ha" (list . (dict "app" "cdi-operator")) | nindent 6 }} - containers: - {{- include "kube_api_rewriter.sidecar_container" . | nindent 6 }} - {{- $kubeRbacProxySettings := dict }} - {{- $_ := set $kubeRbacProxySettings "runAsUserNobody" true }} - {{- $_ := set $kubeRbacProxySettings "ignorePaths" "/proxy/healthz,/proxy/readyz" }} - {{- $_ := set $kubeRbacProxySettings "upstreams" (list - (dict "upstream" "http://127.0.0.1:9090/metrics" "path" "/proxy/metrics" "name" "kube-api-rewriter") - (dict "upstream" "http://127.0.0.1:9090/healthz" "path" "/proxy/healthz" "name" "kube-api-rewriter") - (dict "upstream" "http://127.0.0.1:9090/readyz" "path" "/proxy/readyz" "name" "kube-api-rewriter") - ) }} - {{- include "kube_rbac_proxy.sidecar_container" (tuple . $kubeRbacProxySettings) | nindent 6 }} - - name: cdi-operator - {{- include "helm_lib_module_container_security_context_read_only_root_filesystem_capabilities_drop_all_pss_restricted" . | nindent 8 }} - env: - {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 8 }} - {{- include "cdi_images" . | nindent 8 }} - - name: DEPLOY_CLUSTER_RESOURCES - value: "true" - - name: OPERATOR_VERSION - value: {{ include "cdi_images" . | sha256sum | trunc 7 | quote }} - - name: VERBOSITY - value: "1" - - name: PULL_POLICY - value: IfNotPresent - - name: MONITORING_NAMESPACE - args: - - -metrics_address - - :8080 - image: {{ include "helm_lib_module_image" (list . "cdiOperator") }} - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: metrics - protocol: TCP - - containerPort: 8081 - name: health - protocol: TCP - livenessProbe: - httpGet: - path: /healthz - port: health - scheme: HTTP - initialDelaySeconds: 10 - readinessProbe: - httpGet: - path: /readyz - port: health - scheme: HTTP - initialDelaySeconds: 10 - resources: - requests: - {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 12 }} - {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} - {{- include "cdi_operator_resources" . | nindent 12 }} - {{- end }} - volumeMounts: - {{- include "kube_api_rewriter.kubeconfig_volume_mount" . | nindent 8 }} - {{- include "helm_lib_priority_class" (tuple . $priorityClassName) | nindent 6 }} - {{- include "helm_lib_node_selector" (tuple . "system") | nindent 6 }} - {{- include "helm_lib_tolerations" (tuple . "system") | nindent 6 }} - {{- include "helm_lib_module_pod_security_context_run_as_user_deckhouse" . | nindent 6 }} - serviceAccountName: cdi-operator - volumes: - {{- include "kube_api_rewriter.kubeconfig_volume" . | nindent 6 }} diff --git a/templates/cdi/cdi-operator/rbac-for-us.yaml b/templates/cdi/cdi-operator/rbac-for-us.yaml deleted file mode 100644 index 2f6bc7722b..0000000000 --- a/templates/cdi/cdi-operator/rbac-for-us.yaml +++ /dev/null @@ -1,540 +0,0 @@ ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: d8:containerized-data-importer:cdi-operator -rules: -- apiGroups: - - rbac.authorization.k8s.io - resources: - - clusterrolebindings - - clusterroles - verbs: - - get - - list - - watch - - create - - update - - delete -- apiGroups: - - security.openshift.io - resources: - - securitycontextconstraints - verbs: - - get - - list - - watch - - update - - create -- apiGroups: - - apiextensions.k8s.io - resources: - - customresourcedefinitions - - customresourcedefinitions/status - verbs: - - get - - list - - watch - - create - - update - - delete -- apiGroups: - - cdi.internal.virtualization.deckhouse.io - - upload.cdi.kubevirt.io - resources: - - '*' - verbs: - - '*' -- apiGroups: - - admissionregistration.k8s.io - resources: - - validatingwebhookconfigurations - - mutatingwebhookconfigurations - verbs: - - create - - list - - watch -- apiGroups: - - admissionregistration.k8s.io - resourceNames: - - cdi-internal-virtualization-api-dataimportcron-validate - - cdi-internal-virtualization-api-populator-validate - - cdi-internal-virtualization-api-datavolume-validate - - cdi-internal-virtualization-api-validate - - cdi-internal-virtualization-objecttransfer-api-validate - resources: - - validatingwebhookconfigurations - verbs: - - get - - update - - delete -- apiGroups: - - admissionregistration.k8s.io - resourceNames: - - cdi-internal-virtualization-api-datavolume-mutate - - cdi-internal-virtualization-api-pvc-mutate - resources: - - mutatingwebhookconfigurations - verbs: - - get - - update - - delete -- apiGroups: - - apiregistration.k8s.io - resources: - - apiservices - verbs: - - get - - list - - watch - - create - - update - - delete -- apiGroups: - - authorization.k8s.io - resources: - - subjectaccessreviews - verbs: - - create -- apiGroups: - - "" - resources: - - configmaps - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - persistentvolumeclaims - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - persistentvolumes - verbs: - - get - - list - - watch -- apiGroups: - - storage.k8s.io - resources: - - storageclasses - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - namespaces - verbs: - - get -- apiGroups: - - snapshot.storage.k8s.io - resources: - - volumesnapshots - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - events - verbs: - - create - - patch -- apiGroups: - - "" - resources: - - persistentvolumeclaims - verbs: - - get - - list - - watch - - create - - update - - delete - - deletecollection - - patch -- apiGroups: - - "" - resources: - - persistentvolumes - verbs: - - get - - list - - watch - - update -- apiGroups: - - "" - resources: - - persistentvolumeclaims/finalizers - - pods/finalizers - verbs: - - update -- apiGroups: - - "" - resources: - - pods - - services - verbs: - - get - - list - - watch - - create - - delete -- apiGroups: - - "" - resources: - - configmaps - verbs: - - get - - create -- apiGroups: - - storage.k8s.io - resources: - - storageclasses - - csidrivers - verbs: - - get - - list - - watch -- apiGroups: - - config.openshift.io - resources: - - proxies - - infrastructures - verbs: - - get - - list - - watch -- apiGroups: - - config.openshift.io - resources: - - clusterversions - verbs: - - get -- apiGroups: - - storage.deckhouse.io - resources: - - replicatedstorageclasses - - replicatedstoragepools - verbs: - - get - - list - - watch -- apiGroups: - - snapshot.storage.k8s.io - resources: - - volumesnapshots - - volumesnapshotclasses - - volumesnapshotcontents - verbs: - - get - - list - - watch - - create - - delete -- apiGroups: - - snapshot.storage.k8s.io - resources: - - volumesnapshots - verbs: - - update - - deletecollection -- apiGroups: - - apiextensions.k8s.io - resources: - - customresourcedefinitions - verbs: - - get - - list - - watch -- apiGroups: - - scheduling.k8s.io - resources: - - priorityclasses - verbs: - - get - - list - - watch -- apiGroups: - - image.openshift.io - resources: - - imagestreams - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - secrets - verbs: - - create -- apiGroups: - - internal.virtualization.deckhouse.io - resources: - - internalvirtualizationvirtualmachines/finalizers - verbs: - - update -- apiGroups: - - forklift.cdi.internal.virtualization.deckhouse.io - resources: - - internalvirtualizationovirtvolumepopulators - - internalvirtualizationopenstackvolumepopulators - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - persistentvolumeclaims - verbs: - - get -- apiGroups: - - "" - resources: - - resourcequotas - verbs: - - get - - list - - watch - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: d8:containerized-data-importer:cdi-operator -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: d8:containerized-data-importer:cdi-operator -subjects: -- kind: ServiceAccount - name: cdi-operator - namespace: d8-{{ .Chart.Name }} ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: cdi-operator - namespace: d8-{{ .Chart.Name }} -imagePullSecrets: -- name: virtualization-module-registry ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: cdi-operator - namespace: d8-{{ .Chart.Name }} -rules: -- apiGroups: - - rbac.authorization.k8s.io - resources: - - rolebindings - - roles - verbs: - - get - - list - - watch - - create - - update - - delete -- apiGroups: - - "" - resources: - - serviceaccounts - - configmaps - - events - - secrets - - services - verbs: - - get - - list - - watch - - create - - update - - patch - - delete -- apiGroups: - - apps - resources: - - deployments - - deployments/finalizers - verbs: - - get - - list - - watch - - create - - update - - delete -- apiGroups: - - route.openshift.io - resources: - - routes - - routes/custom-host - verbs: - - get - - list - - watch - - create - - update -- apiGroups: - - config.openshift.io - resources: - - proxies - verbs: - - get - - list - - watch -- apiGroups: - - monitoring.coreos.com - resources: - - servicemonitors - - prometheusrules - verbs: - - get - - list - - watch - - create - - delete - - update - - patch -- apiGroups: - - coordination.k8s.io - resources: - - leases - verbs: - - get - - create - - update -- apiGroups: - - "" - resources: - - secrets - - configmaps - verbs: - - get - - list - - watch - - create -- apiGroups: - - "" - resources: - - configmaps - verbs: - - get - - list - - watch - - create - - update - - delete -- apiGroups: - - "" - resources: - - secrets - verbs: - - get - - list - - watch -- apiGroups: - - batch - resources: - - cronjobs - verbs: - - get - - list - - watch - - create - - update - - deletecollection -- apiGroups: - - batch - resources: - - jobs - verbs: - - create - - deletecollection - - list - - watch -- apiGroups: - - coordination.k8s.io - resources: - - leases - verbs: - - get - - create - - update -- apiGroups: - - networking.k8s.io - resources: - - ingresses - verbs: - - get - - list - - watch -- apiGroups: - - route.openshift.io - resources: - - routes - verbs: - - get - - list - - watch -- apiGroups: - - "" - resources: - - configmaps - verbs: - - get -- apiGroups: - - "" - resources: - - services - - endpoints - - pods - verbs: - - get - - list - - watch ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - {{- include "helm_lib_module_labels" (list .) | nindent 2 }} - name: cdi-operator - namespace: d8-{{ .Chart.Name }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: cdi-operator -subjects: -- kind: ServiceAccount - name: cdi-operator - namespace: d8-{{ .Chart.Name }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: d8:{{ .Chart.Name }}:cdi-operator-rbac-proxy - {{- include "helm_lib_module_labels" (list . (dict "app" "cdi-operator")) | nindent 2 }} -subjects: - - kind: ServiceAccount - name: cdi-operator - namespace: d8-{{ .Chart.Name }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: d8:rbac-proxy diff --git a/templates/cdi/service-monitor.yaml b/templates/cdi/service-monitor.yaml deleted file mode 100644 index 2f48af94b0..0000000000 --- a/templates/cdi/service-monitor.yaml +++ /dev/null @@ -1,33 +0,0 @@ -{{- if (.Values.global.enabledModules | has "operator-prometheus-crd") }} ---- -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - name: {{ .Chart.Name }}-cdi - namespace: d8-monitoring - {{- include "helm_lib_module_labels" (list $ (dict "prometheus" "main")) | nindent 2 }} -spec: - endpoints: - - bearerTokenSecret: - key: token - name: prometheus-token - path: /metrics - port: metrics - scheme: https - tlsConfig: - insecureSkipVerify: true - metricRelabelings: - # rename kubevirt_cdi_* -> d8_internal_virtualization_kubevirt_cdi_* - - action: replace - regex: kubevirt_cdi_(.*) - replacement: d8_internal_virtualization_kubevirt_cdi_$1 - sourceLabels: - - __name__ - targetLabel: __name__ - namespaceSelector: - matchNames: - - d8-{{ .Chart.Name }} - selector: - matchLabels: - prometheus.cdi.internal.virtualization.deckhouse.io: "true" -{{- end }} diff --git a/templates/kubevirt/virt-operator/rbac-for-us.yaml b/templates/kubevirt/virt-operator/rbac-for-us.yaml index 38213700da..d6771a853a 100644 --- a/templates/kubevirt/virt-operator/rbac-for-us.yaml +++ b/templates/kubevirt/virt-operator/rbac-for-us.yaml @@ -372,21 +372,11 @@ rules: - snapshot.internal.virtualization.deckhouse.io resources: - internalvirtualizationvirtualmachinesnapshots - - internalvirtualizationvirtualmachinerestores - internalvirtualizationvirtualmachinesnapshotcontents verbs: - get - list - watch -- apiGroups: - - cdi.internal.virtualization.deckhouse.io - resources: - - internalvirtualizationdatasources - - internalvirtualizationdatavolumes - verbs: - - get - - list - - watch - apiGroups: - instancetype.internal.virtualization.deckhouse.io resources: @@ -613,12 +603,6 @@ rules: - virtualmachineinstances/softreboot verbs: - update -- apiGroups: - - cdi.internal.virtualization.deckhouse.io - resources: - - '*' - verbs: - - '*' - apiGroups: - k8s.cni.cncf.io resources: @@ -918,7 +902,6 @@ rules: resources: - internalvirtualizationvirtualmachinesnapshots - internalvirtualizationvirtualmachinesnapshotcontents - - internalvirtualizationvirtualmachinerestores verbs: - get - delete @@ -1070,7 +1053,6 @@ rules: resources: - internalvirtualizationvirtualmachinesnapshots - internalvirtualizationvirtualmachinesnapshotcontents - - internalvirtualizationvirtualmachinerestores verbs: - get - delete @@ -1177,7 +1159,6 @@ rules: resources: - internalvirtualizationvirtualmachinesnapshots - internalvirtualizationvirtualmachinesnapshotcontents - - internalvirtualizationvirtualmachinerestores verbs: - get - list @@ -1247,6 +1228,19 @@ rules: - get - list - watch +# The embedded KubeVirt install strategy still generates the virt-api and +# virt-controller ClusterRoles with access to CDI resources (cdi.kubevirt.io, +# rewritten to cdi.internal.virtualization.deckhouse.io): the apiserver needs +# get/list/watch on datasources/datavolumes and the controller needs '*'/'*'. +# After the CDI removal these CRDs are gone, but virt-operator must still hold +# these permissions to be allowed to grant them when creating those ClusterRoles +# (RBAC privilege-escalation guard). +- apiGroups: + - cdi.internal.virtualization.deckhouse.io + resources: + - '*' + verbs: + - '*' --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/templates/pre-delete-hook/job.yaml b/templates/pre-delete-hook/job.yaml index 15af983434..773614ad53 100644 --- a/templates/pre-delete-hook/job.yaml +++ b/templates/pre-delete-hook/job.yaml @@ -34,14 +34,6 @@ spec: }, "namespace": "d8-{{ .Chart.Name }}", "name": "config" - }, - { - "gvr": { - "Group": "cdi.internal.virtualization.deckhouse.io", - "Version": "v1beta1", - "Resource": "internalvirtualizationcdis" - }, - "name": "config" } ] resources: diff --git a/templates/pre-delete-hook/rbac-for-us.yaml b/templates/pre-delete-hook/rbac-for-us.yaml index 12f78d785e..56b40a25e7 100644 --- a/templates/pre-delete-hook/rbac-for-us.yaml +++ b/templates/pre-delete-hook/rbac-for-us.yaml @@ -21,13 +21,6 @@ rules: verbs: - get - delete -- apiGroups: - - cdi.internal.virtualization.deckhouse.io - resources: - - internalvirtualizationcdis - verbs: - - get - - delete --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/templates/rbac-to-us.yaml b/templates/rbac-to-us.yaml index 057284d2d7..15fffc9fd8 100644 --- a/templates/rbac-to-us.yaml +++ b/templates/rbac-to-us.yaml @@ -8,7 +8,7 @@ metadata: rules: - apiGroups: ["apps"] resources: ["daemonsets/prometheus-metrics", "deployments/prometheus-metrics"] - resourceNames: ["dvcr", "virtualization-controller", "kube-api-rewriter", "virt-handler", "virt-api", "virt-controller", "cdi-deployment", "cdi-operator"] + resourceNames: ["dvcr", "virtualization-controller", "kube-api-rewriter", "virt-handler", "virt-api", "virt-controller"] verbs: ["get"] {{- if (.Values.global.enabledModules | has "prometheus") }} diff --git a/templates/rbacv2/manage/permissions/manage_internals.yaml b/templates/rbacv2/manage/permissions/manage_internals.yaml index 3498faa55c..406699d0c3 100644 --- a/templates/rbacv2/manage/permissions/manage_internals.yaml +++ b/templates/rbacv2/manage/permissions/manage_internals.yaml @@ -13,8 +13,6 @@ rules: - apiGroups: - cdi.internal.virtualization.deckhouse.io resources: - - internalvirtualizationcdiconfigs - - internalvirtualizationcdis - internalvirtualizationstorageprofiles verbs: - create diff --git a/templates/rbacv2/use/capabilities/manage_internals.yaml b/templates/rbacv2/use/capabilities/manage_internals.yaml index 6e45c53b35..a700181f4a 100644 --- a/templates/rbacv2/use/capabilities/manage_internals.yaml +++ b/templates/rbacv2/use/capabilities/manage_internals.yaml @@ -8,14 +8,6 @@ metadata: rbac.deckhouse.io/kind: use name: d8:use:capability:virtualization:manage_internals rules: - - apiGroups: - - cdi.internal.virtualization.deckhouse.io - resources: - - internalvirtualizationdatavolumes - verbs: - - get - - list - - watch - apiGroups: - internal.virtualization.deckhouse.io resources: diff --git a/templates/rbacv2/use/capabilities/manage_resources.yaml b/templates/rbacv2/use/capabilities/manage_resources.yaml index ac14b0b589..92d8f4ff92 100644 --- a/templates/rbacv2/use/capabilities/manage_resources.yaml +++ b/templates/rbacv2/use/capabilities/manage_resources.yaml @@ -17,7 +17,6 @@ rules: - virtualmachineblockdeviceattachments - virtualmachineipaddresses - virtualmachinemacaddresses - - virtualmachinerestores - virtualmachines - virtualmachinesnapshots verbs: diff --git a/templates/rbacv2/use/capabilities/view_resources.yaml b/templates/rbacv2/use/capabilities/view_resources.yaml index 7d8f69cec0..c1e906b817 100644 --- a/templates/rbacv2/use/capabilities/view_resources.yaml +++ b/templates/rbacv2/use/capabilities/view_resources.yaml @@ -19,7 +19,6 @@ rules: - virtualmachinemacaddresses - virtualmachineoperations - virtualmachinesnapshotoperations - - virtualmachinerestores - virtualmachines - virtualmachinesnapshots - usbdevices diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index 6460800b4a..c9ffb5c6dc 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -12,7 +12,6 @@ rules: resources: - virtualmachines - virtualmachinesnapshots - - virtualmachinerestores - clustervirtualimages - virtualdisks - virtualdisksnapshots @@ -72,7 +71,6 @@ rules: resources: - virtualmachines - virtualmachinesnapshots - - virtualmachinerestores - virtualdisks - virtualimages - virtualdisksnapshots diff --git a/templates/virtualization-controller/_helpers.tpl b/templates/virtualization-controller/_helpers.tpl index 7cc7236ea2..355e2e8582 100644 --- a/templates/virtualization-controller/_helpers.tpl +++ b/templates/virtualization-controller/_helpers.tpl @@ -24,6 +24,8 @@ true fieldPath: metadata.namespace - name: IMPORTER_IMAGE value: {{ include "helm_lib_module_image" (list . "dvcrImporter") }} +- name: DISK_IMPORTER_IMAGE + value: {{ include "helm_lib_module_image" (list . "virtualDiskImporter") }} - name: UPLOADER_IMAGE value: {{ include "helm_lib_module_image" (list . "dvcrUploader") }} - name: BOUNDER_IMAGE diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index 6b46cbfc5a..25e439e5e3 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -91,6 +91,7 @@ rules: - storage.k8s.io resources: - storageclasses + - csidrivers verbs: - get - list @@ -128,7 +129,7 @@ rules: - apiGroups: - cdi.internal.virtualization.deckhouse.io resources: - - internalvirtualizationdatavolumes + - internalvirtualizationstorageprofiles verbs: - get - create @@ -152,17 +153,18 @@ rules: - list - delete - apiGroups: - - snapshot.storage.k8s.io + - snapshot.storage.k8s.io resources: - - volumesnapshots + - volumesnapshots + - volumesnapshotclasses verbs: - - get - - watch - - create - - patch - - update - - list - - delete + - get + - watch + - create + - patch + - update + - list + - delete - apiGroups: - internal.virtualization.deckhouse.io resources: @@ -239,7 +241,6 @@ rules: - virtualmachineclasses - virtualdisksnapshots - virtualmachinesnapshots - - virtualmachinerestores - nodeusbdevices - usbdevices verbs: @@ -267,7 +268,6 @@ rules: - virtualmachineclasses/finalizers - virtualdisksnapshots/finalizers - virtualmachinesnapshots/finalizers - - virtualmachinerestores/finalizers - nodeusbdevices/finalizers - usbdevices/finalizers - virtualmachineipaddresses/status @@ -284,7 +284,6 @@ rules: - virtualmachineclasses/status - virtualdisksnapshots/status - virtualmachinesnapshots/status - - virtualmachinerestores/status - nodeusbdevices/status - usbdevices/status verbs: diff --git a/test/e2e/README.md b/test/e2e/README.md index 5260edfda2..d2179eedf6 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -51,6 +51,17 @@ Additionally, the storage class in the tests can be defined by the environment v STORAGE_CLASS_NAME=linstor-thin-r1 task run ``` +Block-device tests that require both WaitForFirstConsumer and Immediate StorageClasses resolve +them from the default StorageClass and its siblings on the same CSI driver. You can override +the auto-detection explicitly: + +```bash +WFFC_STORAGE_CLASS=linstor-thin-r1-wffc IMMEDIATE_STORAGE_CLASS=linstor-thin-r1 task run +``` + +When the cluster has no default StorageClass, set `WFFC_STORAGE_CLASS` and `IMMEDIATE_STORAGE_CLASS` +explicitly. Auto-detection requires a default StorageClass. + ### E2E configuration Temp directories, prefixes, images and ssh settings can be set in the diff --git a/test/e2e/Taskfile.yaml b/test/e2e/Taskfile.yaml index 6bd012545a..cbc92f0f97 100644 --- a/test/e2e/Taskfile.yaml +++ b/test/e2e/Taskfile.yaml @@ -58,7 +58,7 @@ tasks: - precheck:prepare cmds: - | - go tool ginkgo -v \ + {{if .STORAGE_CLASS_NAME -}}STORAGE_CLASS_NAME="{{.STORAGE_CLASS_NAME}}" {{end -}}go tool ginkgo -v \ --race \ {{if not .FOCUS -}} --label-filter="!Slow" \ @@ -95,7 +95,7 @@ tasks: - precheck:prepare cmds: - | - go tool ginkgo -v \ + {{if .STORAGE_CLASS_NAME -}}STORAGE_CLASS_NAME="{{.STORAGE_CLASS_NAME}}" {{end -}}go tool ginkgo -v \ -p --procs=12 \ --race \ {{if .LABELS -}} diff --git a/test/e2e/blockdevice/cross_csi_provisioning.go b/test/e2e/blockdevice/cross_csi_provisioning.go new file mode 100644 index 0000000000..b672f12950 --- /dev/null +++ b/test/e2e/blockdevice/cross_csi_provisioning.go @@ -0,0 +1,206 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blockdevice + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/utils/ptr" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + + vdbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vd" + vdsnapshotbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vdsnapshot" + vibuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vi" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + "github.com/deckhouse/virtualization/test/e2e/internal/precheck" + "github.com/deckhouse/virtualization/test/e2e/internal/util" +) + +// Creating a block device from a source that lives on a storage class backed by a +// different CSI driver is not supported. The virtualization-controller admission +// webhooks must reject such requests at creation time. +var _ = Describe("CrossCSIDriverProvisioning", Label(precheck.PrecheckDifferentCSIDriverStorageClass), func() { + var ( + f *framework.Framework + ctx context.Context + + scPtr *string + differentSCPtr *string + ) + + BeforeEach(func() { + ctx = context.Background() + f = framework.NewFramework("cross-csi") + f.Before() + DeferCleanup(f.After) + + scPtr = wffcStorageClass() + differentSCPtr = differentCSIDriverStorageClass() + }) + + It("rejects creating a VirtualDisk from a VirtualImage backed by a different CSI driver", Label(precheck.PrecheckDifferentCSIDriverStorageClass), func() { + sourceVI := vibuilder.New( + vibuilder.WithName("vi-source-main-csi"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), + vibuilder.WithDataSourceHTTP(object.ImageTestDataQCOW, nil, nil), + ) + sourceVI.Spec.PersistentVolumeClaim.StorageClass = scPtr + createVirtualImageAndWait(ctx, f, sourceVI) + + target := vdbuilder.New( + vdbuilder.WithName("vd-from-vi-cross-csi"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindVirtualImage, sourceVI.Name), + vdbuilder.WithStorageClass(differentSCPtr), + ) + + expectCrossCSIRejection(ctx, f, target) + }) + + It("rejects creating a VirtualImage from a VirtualDisk backed by a different CSI driver", Label(precheck.PrecheckDifferentCSIDriverStorageClass), func() { + sourceVD := vdbuilder.New( + vdbuilder.WithName("vd-source-main-csi"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceHTTP(&v1alpha2.DataSourceHTTP{URL: object.ImageTestDataQCOW}), + vdbuilder.WithStorageClass(scPtr), + ) + createVirtualDiskAndWait(ctx, f, sourceVD) + + target := vibuilder.New( + vibuilder.WithName("vi-from-vd-cross-csi"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDisk, sourceVD.Name), + ) + target.Spec.PersistentVolumeClaim.StorageClass = differentSCPtr + + expectCrossCSIRejection(ctx, f, target) + }) + + It("rejects creating a VirtualImage from a VirtualImage backed by a different CSI driver", Label(precheck.PrecheckDifferentCSIDriverStorageClass), func() { + sourceVI := vibuilder.New( + vibuilder.WithName("vi-source-main-csi"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), + vibuilder.WithDataSourceHTTP(object.ImageTestDataQCOW, nil, nil), + ) + sourceVI.Spec.PersistentVolumeClaim.StorageClass = scPtr + createVirtualImageAndWait(ctx, f, sourceVI) + + target := vibuilder.New( + vibuilder.WithName("vi-from-vi-cross-csi"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualImage, sourceVI.Name), + ) + target.Spec.PersistentVolumeClaim.StorageClass = differentSCPtr + + expectCrossCSIRejection(ctx, f, target) + }) + + Context("with snapshots", Label(precheck.PrecheckSnapshot), func() { + It("rejects creating a VirtualDisk from a VirtualDiskSnapshot backed by a different CSI driver", Label(precheck.PrecheckDifferentCSIDriverStorageClass), func() { + snapshot := createSourceSnapshotOnMainSC(ctx, f, scPtr, "vd-source-for-snap-cross-csi", "vdsnapshot-source-cross-csi-vd") + + target := vdbuilder.New( + vdbuilder.WithName("vd-from-snapshot-cross-csi"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindVirtualDiskSnapshot, snapshot.Name), + vdbuilder.WithStorageClass(differentSCPtr), + ) + + expectCrossCSIRejection(ctx, f, target) + }) + + It("rejects creating a VirtualImage from a VirtualDiskSnapshot backed by a different CSI driver", Label(precheck.PrecheckDifferentCSIDriverStorageClass), func() { + snapshot := createSourceSnapshotOnMainSC(ctx, f, scPtr, "vd-source-for-snap-cross-csi", "vdsnapshot-source-cross-csi-vi") + + target := vibuilder.New( + vibuilder.WithName("vi-from-snapshot-cross-csi"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDiskSnapshot, snapshot.Name), + ) + target.Spec.PersistentVolumeClaim.StorageClass = differentSCPtr + + expectCrossCSIRejection(ctx, f, target) + }) + }) +}) + +// differentCSIDriverStorageClass returns a pointer to the name of a StorageClass backed +// by a different CSI driver than the main one. Its presence and distinct CSI driver are +// enforced by precheck.PrecheckDifferentCSIDriverStorageClass. +func differentCSIDriverStorageClass() *string { + GinkgoHelper() + + sc := framework.GetConfig().StorageClass.DifferentCSIDriverStorageClass + Expect(sc).NotTo(BeNil(), + "no StorageClass with a CSI driver different from the main one was found "+ + "(discovered automatically; enforced by the %q precheck)", + precheck.PrecheckDifferentCSIDriverStorageClass) + + return ptr.To(sc.Name) +} + +// createSourceSnapshotOnMainSC provisions a VirtualDisk on the main storage class and a +// ready VirtualDiskSnapshot of it, returning the snapshot. +func createSourceSnapshotOnMainSC(ctx context.Context, f *framework.Framework, sc *string, vdName, snapshotName string) *v1alpha2.VirtualDiskSnapshot { + GinkgoHelper() + + sourceVD := vdbuilder.New( + vdbuilder.WithName(vdName), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceHTTP(&v1alpha2.DataSourceHTTP{URL: object.ImageTestDataQCOW}), + vdbuilder.WithStorageClass(sc), + ) + createVirtualDiskAndWait(ctx, f, sourceVD) + + snapshot := vdsnapshotbuilder.New( + vdsnapshotbuilder.WithName(snapshotName), + vdsnapshotbuilder.WithNamespace(f.Namespace().Name), + vdsnapshotbuilder.WithVirtualDiskName(sourceVD.Name), + vdsnapshotbuilder.WithRequiredConsistency(true), + ) + + By("Creating the source VirtualDiskSnapshot", func() { + err := f.CreateWithDeferredDeletion(ctx, snapshot) + Expect(err).NotTo(HaveOccurred()) + + util.UntilObjectPhase(ctx, string(v1alpha2.VirtualDiskSnapshotPhaseReady), framework.LongTimeout, snapshot) + }) + + return snapshot +} + +// expectCrossCSIRejection attempts to create obj and asserts that the admission webhook +// rejects it because the source lives on a different CSI driver. +func expectCrossCSIRejection(ctx context.Context, f *framework.Framework, obj crclient.Object) { + GinkgoHelper() + + By("Expecting the webhook to reject creation from a source on a different CSI driver", func() { + err := f.CreateWithDeferredDeletion(ctx, obj) + Expect(err).To(HaveOccurred(), "creation from a source on a different CSI driver must be rejected by the webhook") + Expect(err.Error()).To(ContainSubstring("provisioner"), + "the rejection error should explain the storage class provisioner mismatch") + }) +} diff --git a/test/e2e/blockdevice/format_expectations.go b/test/e2e/blockdevice/format_expectations.go new file mode 100644 index 0000000000..19c68239ca --- /dev/null +++ b/test/e2e/blockdevice/format_expectations.go @@ -0,0 +1,156 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blockdevice + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/imageformat" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/volumemode" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + "github.com/deckhouse/virtualization/test/e2e/internal/rewrite" +) + +// expectedFormatForStorageClass returns the on-disk image format for a PVC backed by +// scName. Block storage classes hold a flat raw disk; filesystem storage classes keep +// a qcow2 file. The same rule applies to VirtualImages and VirtualDisks on PVC. +func expectedFormatForStorageClass(ctx context.Context, f *framework.Framework, scName string) string { + GinkgoHelper() + + if storageClassVolumeMode(ctx, f, scName) == corev1.PersistentVolumeBlock { + return imageformat.FormatRAW + } + return imageformat.FormatQCOW2 +} + +// expectedVirtualImageFormat returns the image format actually stored for vi. +// On PVC the format follows the target storage class volume mode. On DVCR, +// object-ref imports that copy from a block volume inherit the source storage +// class format; file-based imports (HTTP, registry, CVI, upload) keep qcow2. +func expectedVirtualImageFormat(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage) string { + GinkgoHelper() + + if vi.Spec.DataSource.HTTP != nil && vi.Spec.DataSource.HTTP.URL == object.ImageURLUbuntuISO { + return imageformat.FormatISO + } + if vi.Spec.DataSource.ObjectRef != nil && + vi.Spec.DataSource.ObjectRef.Kind == v1alpha2.VirtualImageObjectRefKindClusterVirtualImage && + vi.Spec.DataSource.ObjectRef.Name == object.PrecreatedCVIUbuntuISO { + return imageformat.FormatISO + } + + if vi.Spec.Storage == v1alpha2.StoragePersistentVolumeClaim { + return expectedFormatForStorageClass(ctx, f, ptr.Deref(vi.Spec.PersistentVolumeClaim.StorageClass, "")) + } + + if scName := virtualImageSourceStorageClassName(ctx, f, vi); scName != "" { + return expectedFormatForStorageClass(ctx, f, scName) + } + + return imageformat.FormatQCOW2 +} + +func virtualImageSourceStorageClassName(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage) string { + GinkgoHelper() + + ref := vi.Spec.DataSource.ObjectRef + if ref == nil { + return "" + } + + switch ref.Kind { + case v1alpha2.VirtualImageObjectRefKindVirtualDiskSnapshot: + vdSnapshot := &v1alpha2.VirtualDiskSnapshot{} + err := f.Clients.GenericClient().Get(ctx, crclient.ObjectKey{Namespace: vi.Namespace, Name: ref.Name}, vdSnapshot) + Expect(err).NotTo(HaveOccurred()) + return virtualDiskStorageClassName(ctx, f, vdSnapshot.Namespace, vdSnapshot.Spec.VirtualDiskName) + + case v1alpha2.VirtualImageObjectRefKindVirtualDisk: + return virtualDiskStorageClassName(ctx, f, vi.Namespace, ref.Name) + + case v1alpha2.VirtualImageObjectRefKindVirtualImage: + sourceVI := &v1alpha2.VirtualImage{} + err := f.Clients.GenericClient().Get(ctx, crclient.ObjectKey{Namespace: vi.Namespace, Name: ref.Name}, sourceVI) + Expect(err).NotTo(HaveOccurred()) + if sourceVI.Spec.Storage != v1alpha2.StoragePersistentVolumeClaim { + return "" + } + return ptr.Deref(sourceVI.Spec.PersistentVolumeClaim.StorageClass, "") + + default: + return "" + } +} + +func virtualDiskStorageClassName(ctx context.Context, f *framework.Framework, namespace, name string) string { + GinkgoHelper() + + vd := &v1alpha2.VirtualDisk{} + err := f.Clients.GenericClient().Get(ctx, crclient.ObjectKey{Namespace: namespace, Name: name}, vd) + Expect(err).NotTo(HaveOccurred()) + + if vd.Status.StorageClassName != "" { + return vd.Status.StorageClassName + } + return ptr.Deref(vd.Spec.PersistentVolumeClaim.StorageClass, "") +} + +func storageClassVolumeMode(ctx context.Context, f *framework.Framework, scName string) corev1.PersistentVolumeMode { + GinkgoHelper() + + sc := storageClassByName(ctx, f, scName) + modeGetter := volumemode.NewVolumeAndAccessModesGetter(f.GenericClient(), storageProfileGetter(f)) + mode, _, err := modeGetter.GetVolumeAndAccessModes(ctx, sc, sc) + Expect(err).NotTo(HaveOccurred(), "failed to resolve volume mode for StorageClass %q", sc.Name) + return mode +} + +func storageClassByName(ctx context.Context, f *framework.Framework, name string) *storagev1.StorageClass { + GinkgoHelper() + + if name == "" { + sc := framework.GetConfig().StorageClass.DefaultStorageClass + Expect(sc).NotTo(BeNil(), "default StorageClass not found") + return sc + } + + got, err := f.KubeClient().StorageV1().StorageClasses().Get(ctx, name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to get StorageClass %q", name) + return got +} + +func storageProfileGetter(f *framework.Framework) func(ctx context.Context, name string) (*cdiv1.StorageProfile, error) { + return func(ctx context.Context, name string) (*cdiv1.StorageProfile, error) { + obj := &rewrite.StorageProfile{} + err := f.RewriteClient().Get(ctx, name, obj) + if err != nil { + return nil, err + } + return obj.StorageProfile, nil + } +} diff --git a/test/e2e/blockdevice/quota_exhausted.go b/test/e2e/blockdevice/quota_exhausted.go new file mode 100644 index 0000000000..8256c77a19 --- /dev/null +++ b/test/e2e/blockdevice/quota_exhausted.go @@ -0,0 +1,160 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blockdevice + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + vdbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vd" + vibuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vi" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + rqobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/resourcequota" + vdobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vd" + viobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vi" + "github.com/deckhouse/virtualization/test/e2e/internal/precheck" +) + +// quotaExhaustedQuotaName is the name of the project ResourceQuota that +// blocks every Pod and PersistentVolumeClaim creation in the test namespace, +// forcing the virtualization-controller to surface the quota-exceeded condition +// on resources that create backing PVCs. Importer/ bounder Pods carry the +// resource-quota-overrides.deckhouse.io/ignore label and are not blocked. +const quotaExhaustedQuotaName = "v12n-e2e-block-pods-and-pvcs" + +var _ = Describe("QuotaExhausted", Ordered, Label(precheck.PrecheckImmediateStorageClass), func() { + var ( + f *framework.Framework + ctx context.Context + + immediateSCPtr *string + baseVD *v1alpha2.VirtualDisk + ) + + BeforeAll(func() { + ctx = context.Background() + f = framework.NewFramework("") + f.Before() + DeferCleanup(f.After) + setupProject(ctx, f, "quota-exhausted") + + // This spec provisions objects without a VirtualMachine consumer + // (the base disk below must become Ready on its own, and the + // quota-blocked objects are only ever observed for the + // QuotaExceeded reason), so every object uses the immediate + // StorageClass; on a WaitForFirstConsumer StorageClass the base + // disk would park in WaitForFirstConsumer forever. + immediateSCPtr = immediateStorageClass() + + // Create a base VirtualDisk before applying the quota so that + // the ClusterVirtualImage spec below has a valid object-ref + // source to reference. Sourcing a CVI from a VD on PVC makes the + // CVI importer Pod run in the user's namespace, which is exactly + // what we need to exercise the user-namespace quota path for + // CVIs. See ImporterService.GetPodSettingsWithPVC. + baseVD = vdbuilder.New( + vdbuilder.WithName("vd-quota-source"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindClusterVirtualImage, object.PrecreatedCVITestDataQCOW), + vdbuilder.WithStorageClass(immediateSCPtr), + ) + createVirtualDiskAndWait(ctx, f, baseVD) + + applyBlockingResourceQuota(ctx, f) + }) + + It("VirtualDisk reports QuotaExceeded reason on a fresh Ready condition", Label(precheck.PrecheckImmediateStorageClass), func() { + vd := vdbuilder.New( + vdbuilder.WithName("vd-quota-cvi"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindClusterVirtualImage, object.PrecreatedCVITestDataQCOW), + vdbuilder.WithStorageClass(immediateSCPtr), + ) + + obs := vdobs.StartObserver(ctx, f, vd) + + By("Creating VirtualDisk", func() { + err := f.CreateWithDeferredDeletion(ctx, vd) + Expect(err).NotTo(HaveOccurred()) + }) + + err := obs.WaitFor(vdobs.BeQuotaExceeded(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + It("VirtualImage on PVC reports a quota-exceeded ProvisioningFailed Ready condition", Label(precheck.PrecheckImmediateStorageClass), func() { + vi := vibuilder.New( + vibuilder.WithName("vi-pvc-quota"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVITestDataQCOW), + ) + vi.Spec.PersistentVolumeClaim.StorageClass = immediateSCPtr + + obs := viobs.StartObserver(ctx, f, vi) + + By("Creating VirtualImage on PVC", func() { + err := f.CreateWithDeferredDeletion(ctx, vi) + Expect(err).NotTo(HaveOccurred()) + }) + + err := obs.WaitFor(viobs.BeQuotaExceeded(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) +}) + +// applyBlockingResourceQuota installs a ResourceQuota in the framework +// namespace that hard-limits Pods and PersistentVolumeClaims to zero, +// thereby rejecting every importer/uploader Pod and every backing PVC +// the virtualization-controller tries to create for new resources. +// +// The function blocks until the kube-apiserver has populated the +// ResourceQuota .status.hard fields, ensuring that admission-time +// enforcement is in effect by the time the function returns. +func applyBlockingResourceQuota(ctx context.Context, f *framework.Framework) { + GinkgoHelper() + + quota := &corev1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: quotaExhaustedQuotaName, + Namespace: f.Namespace().Name, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceName("count/pods"): resource.MustParse("0"), + corev1.ResourceName("count/persistentvolumeclaims"): resource.MustParse("0"), + }, + }, + } + + By("Applying a project-blocking ResourceQuota", func() { + obs := rqobs.StartObserver(ctx, f, quota) + + err := f.CreateWithDeferredDeletion(ctx, quota) + Expect(err).NotTo(HaveOccurred(), "failed to create quota %q", quota.Name) + + err = obs.WaitFor(rqobs.BeEnforced(), framework.MiddleTimeout) + Expect(err).NotTo(HaveOccurred(), "ResourceQuota %q was not enforced by the project", quota.Name) + }) +} diff --git a/test/e2e/blockdevice/storage_profile_creation.go b/test/e2e/blockdevice/storage_profile_creation.go new file mode 100644 index 0000000000..f3e2dac9df --- /dev/null +++ b/test/e2e/blockdevice/storage_profile_creation.go @@ -0,0 +1,145 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blockdevice + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/rand" + + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" + scobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/storageclass" + spobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/storageprofile" + "github.com/deckhouse/virtualization/test/e2e/internal/precheck" + "github.com/deckhouse/virtualization/test/e2e/internal/rewrite" +) + +// The virtualization-controller storageprofile controller must create a StorageProfile +// for every StorageClass added to the cluster and remove it once the StorageClass is +// deleted. A plain StorageClass is used so the test controls the full lifecycle: +// SDS-managed StorageClasses cannot be deleted directly and are not removed when the +// owning ReplicatedStorageClass is deleted. +var _ = Describe("StorageProfileCreation", Label(precheck.NoPrecheck), func() { + var ( + f *framework.Framework + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + f = framework.NewFramework("") + f.Before() + DeferCleanup(f.After) + }) + + It("creates a StorageProfile when a StorageClass is added and deletes it when the StorageClass is removed", func() { + // Cluster-scoped name with a random suffix to stay unique across parallel runs. + name := fmt.Sprintf("v12n-e2e-storageprofile-%s", rand.String(6)) + + scObs := scobs.StartObserver(ctx, f, name) + spObs := spobs.StartObserver(ctx, f, name) + + By("Creating a test StorageClass", func() { + _, err := f.KubeClient().StorageV1().StorageClasses().Create(ctx, newTestStorageClass(name), metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to create StorageClass %q", name) + + DeferCleanup(func() { + err := f.KubeClient().StorageV1().StorageClasses().Delete(context.Background(), name, metav1.DeleteOptions{}) + Expect(err == nil || k8serrors.IsNotFound(err)).To(BeTrue(), + "failed to delete StorageClass %q: %v", name, err) + }) + }) + + By("Waiting for the StorageProfile to be created for the new StorageClass", func() { + err := scObs.WaitFor(scobs.BeAvailable(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred(), "StorageClass %q was not available", name) + + err = spObs.WaitFor(spobs.BeReady(name), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred(), "StorageProfile %q was not created for the new StorageClass", name) + }) + + By("Deleting the test StorageClass", func() { + err := f.KubeClient().StorageV1().StorageClasses().Delete(ctx, name, metav1.DeleteOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to delete StorageClass %q", name) + }) + + By("Waiting for the StorageClass to be deleted", func() { + err := waitForStorageClassDeleted(ctx, f, name, framework.LongTimeout) + Expect(err).NotTo(HaveOccurred(), "StorageClass %q was not deleted", name) + }) + + By("Waiting for the StorageProfile to be deleted for the removed StorageClass", func() { + err := waitForStorageProfileDeleted(ctx, f, name, framework.LongTimeout) + Expect(err).NotTo(HaveOccurred(), "StorageProfile %q was not deleted after its StorageClass was removed", name) + }) + }) +}) + +func newTestStorageClass(name string) *storagev1.StorageClass { + reclaimPolicy := corev1.PersistentVolumeReclaimDelete + volumeBindingMode := storagev1.VolumeBindingWaitForFirstConsumer + return &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Provisioner: "kubernetes.io/no-provisioner", + ReclaimPolicy: &reclaimPolicy, + VolumeBindingMode: &volumeBindingMode, + } +} + +func waitForStorageClassDeleted(ctx context.Context, f *framework.Framework, name string, timeout time.Duration) error { + return observer.WaitForDeleted( + ctx, + f.KubeClient().StorageV1().StorageClasses(), + name, + "", + timeout, + func(ctx context.Context) (bool, error) { + _, err := f.KubeClient().StorageV1().StorageClasses().Get(ctx, name, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + return true, nil + } + return false, err + }, + ) +} + +func waitForStorageProfileDeleted(ctx context.Context, f *framework.Framework, name string, timeout time.Duration) error { + spGVR := rewrite.StorageProfile{}.GVR() + return observer.WaitForDeleted( + ctx, + observer.DynamicWatcher(f.DynamicClient(), spGVR, ""), + name, + "", + timeout, + func(ctx context.Context) (bool, error) { + _, err := f.DynamicClient().Resource(spGVR).Get(ctx, name, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + return true, nil + } + return false, err + }, + ) +} diff --git a/test/e2e/blockdevice/virtual_disk_creation.go b/test/e2e/blockdevice/virtual_disk_creation.go new file mode 100644 index 0000000000..0c56e3053d --- /dev/null +++ b/test/e2e/blockdevice/virtual_disk_creation.go @@ -0,0 +1,1023 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blockdevice + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + + vdbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vd" + vdsnapshotbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vdsnapshot" + vibuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vi" + vmbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vm" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/config" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + vdobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vd" + vdsnapshotobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vdsnapshot" + viobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vi" + vmobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vm" + "github.com/deckhouse/virtualization/test/e2e/internal/precheck" + "github.com/deckhouse/virtualization/test/e2e/internal/util" +) + +const vdCreationBlankSize = "64Mi" + +// progressUpdateInterval is the maximum time the reported import progress is +// allowed to stay unchanged between observed status updates. The controllers +// refresh progress roughly every two seconds while an importer is active, but +// the e2e contract allows a wider 10 second window. +// +// progressBoundaryBudget is the more lenient budget granted only to 0%, 50% and +// 100%, where provisioning may legitimately pause. +const ( + progressUpdateInterval = 10 * time.Second + progressBoundaryBudget = time.Minute +) + +const hostnameNodeSelectorKey = "kubernetes.io/hostname" + +var pinnedScenarioNodes sync.Map + +var _ = Describe("VirtualDiskCreation", Label( + precheck.PrecheckDefaultStorageClass, +), func() { + var ( + f *framework.Framework + ctx context.Context + + scPtr *string + ) + + BeforeEach(func() { + ctx = context.Background() + f = framework.NewFramework("") + f.Before() + DeferCleanup(f.After) + setupProject(ctx, f, "vd-creation") + + scPtr = defaultStorageClass() + }) + + It("provisions a VirtualDisk from HTTP data source", func() { + vd := vdbuilder.New( + vdbuilder.WithName("vd-http"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceHTTP(&v1alpha2.DataSourceHTTP{URL: object.ImageURLAlpineBIOS}), + vdbuilder.WithStorageClass(scPtr), + ) + + createVirtualDiskAndRunVM(ctx, f, vd) + }) + + It("provisions a VirtualDisk from Upload data source", func() { + vd := vdbuilder.New( + vdbuilder.WithName("vd-upload"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDatasource(&v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeUpload, + }), + vdbuilder.WithStorageClass(scPtr), + ) + + var uploadFilePath string + By("Downloading source image to upload", func() { + var err error + uploadFilePath, err = downloadImageToTempFile(object.ImageURLAlpineBIOS) + Expect(err).NotTo(HaveOccurred(), "failed to download upload source image") + DeferCleanup(func() { + removeErr := os.Remove(uploadFilePath) + Expect(removeErr == nil || errors.Is(removeErr, os.ErrNotExist)).To(BeTrue(), + "failed to remove upload source file %q: %v", uploadFilePath, removeErr) + }) + }) + + obs := vdobs.StartObserver(ctx, f, vd) + obs.Never(vdobs.BeFailed()) + obs.Always(vdobs.BeStorageClassReady()) + obs.Always(vdobs.BeDataSourceReady()) + obs.Always(vdobs.HaveValidPhaseTransitions()) + obs.Always(vdobs.HaveValidProgress(streamedVirtualDiskProgress(), progressUpdateInterval, progressBoundaryBudget)) + + By("Creating VirtualDisk", func() { + err := f.CreateWithDeferredDeletion(ctx, vd) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Waiting for the VirtualDisk to expose upload URLs", func() { + err := obs.WaitFor(vdobs.BeReadyForUserUpload(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Allowing ingress-nginx and the controller to reach the uploader pod (workaround)", func() { + err := allowIngressToUploaderNetworkPolicy(ctx, f, vd.Namespace, vd.UID) + Expect(err).NotTo(HaveOccurred(), "failed to patch uploader NetworkPolicy") + }) + + By("Uploading data to the VirtualDisk", func() { + err := f.Clients.GenericClient().Get(ctx, crclient.ObjectKeyFromObject(vd), vd) + Expect(err).NotTo(HaveOccurred()) + Expect(vd.Status.ImageUploadURLs).NotTo(BeNil()) + Expect(vd.Status.ImageUploadURLs.External).NotTo(BeEmpty()) + + err = doRetriableUploadAttempt(vd.Status.ImageUploadURLs.External, uploadFilePath) + Expect(err).NotTo(HaveOccurred(), "upload should succeed") + }) + + // On a WaitForFirstConsumer storage class the uploaded data lands in DVCR, and the + // final import into the disk's volume only runs once the disk has a consumer; the + // VirtualMachine created below is that consumer. + runVirtualMachineFromDisks(ctx, f, observedDisk{vd: vd, obs: obs}) + }) + + It("provisions a VirtualDisk from ContainerImage (registry) data source", func() { + vd := vdbuilder.New( + vdbuilder.WithName("vd-registry"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceContainerImage(object.ImageURLContainerImage, "", nil), + vdbuilder.WithStorageClass(scPtr), + ) + + createVirtualDiskAndRunVM(ctx, f, vd) + }) + + It("provisions a VirtualDisk from a VirtualImage on DVCR", func() { + baseVI := vibuilder.New( + vibuilder.WithName("vi-source-dvcr"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), + // The source image type is incidental here (the scenario tests a VD from a + // VI on DVCR), so create the base image from a precreated ClusterVirtualImage. + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + ) + + viObs := viobs.StartObserver(ctx, f, baseVI) + viObs.Never(viobs.BeFailed()) + viObs.Always(viobs.HaveFormat(expectedVirtualImageFormat(ctx, f, baseVI))) + + By("Creating base VirtualImage on DVCR", func() { + err := f.CreateWithDeferredDeletion(ctx, baseVI) + Expect(err).NotTo(HaveOccurred()) + + err = viObs.WaitFor(viobs.BeReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + rememberVirtualImageNode(ctx, f, baseVI) + + vd := vdbuilder.New( + vdbuilder.WithName("vd-from-vi"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindVirtualImage, baseVI.Name), + vdbuilder.WithStorageClass(scPtr), + ) + + createVirtualDiskAndRunVM(ctx, f, vd, withIntermediateProgress()) + }) + + It("provisions a VirtualDisk from a VirtualImage on PVC", func() { + baseVI := vibuilder.New( + vibuilder.WithName("vi-source-pvc"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), + vibuilder.WithDataSourceHTTP(object.ImageURLAlpineBIOS, nil, nil), + ) + baseVI.Spec.PersistentVolumeClaim.StorageClass = scPtr + + viObs := viobs.StartObserver(ctx, f, baseVI) + viObs.Never(viobs.BeFailed()) + viObs.Always(viobs.HaveFormat(expectedVirtualImageFormat(ctx, f, baseVI))) + + By("Creating base VirtualImage on PVC", func() { + err := f.CreateWithDeferredDeletion(ctx, baseVI) + Expect(err).NotTo(HaveOccurred()) + + err = viObs.WaitFor(viobs.BeReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + rememberVirtualImageNode(ctx, f, baseVI) + + vd := vdbuilder.New( + vdbuilder.WithName("vd-from-vi-pvc"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindVirtualImage, baseVI.Name), + vdbuilder.WithStorageClass(scPtr), + ) + + createVirtualDiskAndRunVM(ctx, f, vd, withoutStreamingProgress()) + }) + + // TODO(sc): disabled while VirtualDiskCreation is constrained to a single + // default StorageClass. Re-enable when different-StorageClass scenarios are + // needed again. + /* + It("provisions a VirtualDisk from a VirtualImage on PVC backed by a different storage class of the same CSI driver", func() { + baseVI := vibuilder.New( + vibuilder.WithName("vi-source-pvc-other-sc"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), + // The source image type is incidental here (the scenario tests cloning from + // a PVC-backed VI), so source the base image from a CVI. + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + ) + baseVI.Spec.PersistentVolumeClaim.StorageClass = scPtr + + viObs := viobs.StartObserver(ctx, f, baseVI) + viObs.Never(viobs.BeFailed()) + viObs.Always(viobs.HaveFormat(expectedVirtualImageFormat(ctx, f, baseVI))) + + By("Creating base VirtualImage on PVC with the default storage class "+*scPtr, func() { + err := f.CreateWithDeferredDeletion(ctx, baseVI) + Expect(err).NotTo(HaveOccurred()) + + err = viObs.WaitFor(viobs.BeReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + vd := vdbuilder.New( + vdbuilder.WithName("vd-from-vi-other-sc"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindVirtualImage, baseVI.Name), + vdbuilder.WithStorageClass(scPtr), + ) + + bootVD := vdbuilder.New( + vdbuilder.WithName("vd-from-vi-other-sc-boot"), + vdbuilder.WithNamespace(f.Namespace().Name), + // The boot disk is incidental here; the scenario checks that the + // cloned disk provisions and attaches successfully. + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + vdbuilder.WithStorageClass(scPtr), + ) + + bootObs := startVirtualDisk(ctx, f, bootVD, withIntermediateProgress()) + // PVC-backed source provisioning does not stream importer progress. + cloneObs := startVirtualDisk(ctx, f, vd, withoutStreamingProgress()) + + runVirtualMachineFromDisks(ctx, f, + observedDisk{vd: bootVD, obs: bootObs}, + observedDisk{vd: vd, obs: cloneObs}, + ) + }) + */ + + It("provisions a VirtualDisk from a ClusterVirtualImage", func() { + vd := vdbuilder.New( + vdbuilder.WithName("vd-from-cvi"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + vdbuilder.WithStorageClass(scPtr), + ) + + createVirtualDiskAndRunVM(ctx, f, vd, withIntermediateProgress()) + }) + + It("provisions a blank VirtualDisk and attaches it to a running VirtualMachine", func() { + blankVD := vdbuilder.New( + vdbuilder.WithName("vd-blank"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithPersistentVolumeClaim(scPtr, ptr.To(resource.MustParse(vdCreationBlankSize))), + ) + + // A blank disk has no operating system, so the VM boots from a bootable + // VirtualDisk and the blank disk is attached as an additional volume. Both disks + // are created first and the VM provides the consumer that triggers provisioning + // (required for WaitForFirstConsumer storage classes). + bootVD := vdbuilder.New( + vdbuilder.WithName("vd-blank-boot"), + vdbuilder.WithNamespace(f.Namespace().Name), + // The boot disk is incidental here (the scenario tests the blank disk), so + // source it from a precreated ClusterVirtualImage instead of HTTP. + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + vdbuilder.WithStorageClass(scPtr), + ) + + bootObs := startVirtualDisk(ctx, f, bootVD, withIntermediateProgress()) + // A blank disk is provisioned by the CSI driver and may legitimately jump + // straight from 0% to 100%. + blankObs := startVirtualDisk(ctx, f, blankVD, withoutStreamingProgress()) + + runVirtualMachineFromDisks(ctx, f, + observedDisk{vd: bootVD, obs: bootObs}, + observedDisk{vd: blankVD, obs: blankObs}, + ) + }) + + Context("with snapshots", Label(precheck.PrecheckSnapshot), func() { + It("provisions a VirtualDisk from a VirtualDiskSnapshot", func() { + baseVD := vdbuilder.New( + vdbuilder.WithName("vd-source-for-snapshot"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceHTTP(&v1alpha2.DataSourceHTTP{URL: object.ImageURLAlpineBIOS}), + vdbuilder.WithStorageClass(scPtr), + ) + + // Boot a VM from the source disk so it provisions (the VM is its consumer on + // WaitForFirstConsumer storage classes) and so the consistent snapshot below + // can freeze the guest filesystem via the agent. + createVirtualDiskAndRunVM(ctx, f, baseVD) + rememberVirtualDiskNode(ctx, f, baseVD) + + vdSnapshot := vdsnapshotbuilder.New( + vdsnapshotbuilder.WithName("vd-snapshot"), + vdsnapshotbuilder.WithNamespace(f.Namespace().Name), + vdsnapshotbuilder.WithVirtualDiskName(baseVD.Name), + vdsnapshotbuilder.WithRequiredConsistency(true), + ) + + snapObs := vdsnapshotobs.StartObserver(ctx, f, vdSnapshot) + By("Creating VirtualDiskSnapshot", func() { + err := f.CreateWithDeferredDeletion(ctx, vdSnapshot) + Expect(err).NotTo(HaveOccurred()) + + err = snapObs.WaitFor(vdsnapshotobs.BeReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + vd := vdbuilder.New( + vdbuilder.WithName("vd-from-snapshot"), + vdbuilder.WithNamespace(f.Namespace().Name), + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindVirtualDiskSnapshot, vdSnapshot.Name), + vdbuilder.WithStorageClass(scPtr), + ) + + createVirtualDiskAndRunVM(ctx, f, vd, withoutStreamingProgress()) + }) + }) +}) + +// wffcStorageClass returns a pointer to the name of the WaitForFirstConsumer StorageClass +// that block-device tests use to provision the scenario's main VirtualDisks and +// VirtualImages. Its presence and WaitForFirstConsumer volume binding mode are enforced by +// precheck.PrecheckWFFCStorageClass. +func wffcStorageClass() *string { + GinkgoHelper() + + sc := framework.GetConfig().StorageClass.WFFCStorageClass + Expect(sc).NotTo(BeNil(), + "WFFC StorageClass not found: set %s or configure a default StorageClass (enforced by the %q precheck)", + config.WFFCStorageClassEnv, precheck.PrecheckWFFCStorageClass) + + return ptr.To(sc.Name) +} + +// immediateStorageClass returns a pointer to the name of the immediate StorageClass, used as +// the "other" StorageClass (same CSI driver as the WFFC one) when a source object must +// live on a different StorageClass than the produced one, and to provision dependent objects +// that must become Ready without a consumer. Its presence and Immediate volume binding mode are +// enforced by precheck.PrecheckImmediateStorageClass; the shared CSI driver with the WFFC +// StorageClass is enforced by precheck.PrecheckSameCSIDriverStorageClass. +func immediateStorageClass() *string { + GinkgoHelper() + + sc := framework.GetConfig().StorageClass.ImmediateStorageClass + Expect(sc).NotTo(BeNil(), + "immediate StorageClass not found: set %s or configure a default StorageClass (enforced by the %q precheck)", + config.ImmediateStorageClassEnv, precheck.PrecheckImmediateStorageClass) + + return ptr.To(sc.Name) +} + +// expectedDiskPhaseBeforeVM returns the phase predicate the disk must satisfy before its +// consuming VirtualMachine is created: a WaitForFirstConsumer disk parks in the +// WaitForFirstConsumer phase until the VM (its consumer) is scheduled, while an Immediate +// disk provisions to Ready on its own. +func expectedDiskPhaseBeforeVM(ctx context.Context, f *framework.Framework, vd *v1alpha2.VirtualDisk) vdobs.Predicate { + GinkgoHelper() + + if storageClassIsWaitForFirstConsumer(ctx, f, ptr.Deref(vd.Spec.PersistentVolumeClaim.StorageClass, "")) { + return vdobs.BeWaitForFirstConsumer() + } + return vdobs.BeReady() +} + +// storageClassIsWaitForFirstConsumer reports whether the named StorageClass (or the cluster +// default, when name is empty) uses the WaitForFirstConsumer volume binding mode. +func storageClassIsWaitForFirstConsumer(ctx context.Context, f *framework.Framework, name string) bool { + GinkgoHelper() + + var sc *storagev1.StorageClass + if name == "" { + sc = framework.GetConfig().StorageClass.DefaultStorageClass + Expect(sc).NotTo(BeNil(), "default StorageClass not found") + } else { + got, err := f.KubeClient().StorageV1().StorageClasses().Get(ctx, name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred(), "failed to get StorageClass %q", name) + sc = got + } + + return sc.VolumeBindingMode != nil && *sc.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer +} + +func rememberVirtualImageNode(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage) { + GinkgoHelper() + + if vi.Spec.Storage != v1alpha2.StoragePersistentVolumeClaim { + return + } + + err := f.Clients.GenericClient().Get(ctx, crclient.ObjectKeyFromObject(vi), vi) + Expect(err).NotTo(HaveOccurred()) + + rememberNodeFromPVC(ctx, f, vi.Namespace, vi.Status.Target.PersistentVolumeClaim) +} + +func rememberVirtualDiskNode(ctx context.Context, f *framework.Framework, vd *v1alpha2.VirtualDisk) { + GinkgoHelper() + + err := f.Clients.GenericClient().Get(ctx, crclient.ObjectKeyFromObject(vd), vd) + Expect(err).NotTo(HaveOccurred()) + + rememberNodeFromPVC(ctx, f, vd.Namespace, vd.Status.Target.PersistentVolumeClaim) +} + +func rememberNodeFromPVC(ctx context.Context, f *framework.Framework, namespace, pvcName string) { + GinkgoHelper() + + if pvcName == "" { + return + } + + node := nodeForPVC(ctx, f, namespace, pvcName) + if node == "" { + return + } + + rememberScenarioNode(f, node) +} + +func rememberScenarioNode(f *framework.Framework, node string) { + GinkgoHelper() + + if node == "" { + return + } + + namespace := f.Namespace().Name + if existing, ok := pinnedScenarioNodes.Load(namespace); ok { + Expect(existing).To(Equal(node), "scenario resources must stay on one node") + return + } + + pinnedScenarioNodes.Store(namespace, node) +} + +func scenarioNode(f *framework.Framework) (string, bool) { + GinkgoHelper() + + node, ok := pinnedScenarioNodes.Load(f.Namespace().Name) + if !ok { + return "", false + } + + return node.(string), true +} + +func nodeForPVC(ctx context.Context, f *framework.Framework, namespace, pvcName string) string { + GinkgoHelper() + + var pvc corev1.PersistentVolumeClaim + err := f.Clients.GenericClient().Get(ctx, types.NamespacedName{Name: pvcName, Namespace: namespace}, &pvc) + Expect(err).NotTo(HaveOccurred(), "failed to get PVC %s/%s", namespace, pvcName) + if pvc.Spec.VolumeName == "" { + return "" + } + + var pv corev1.PersistentVolume + err = f.Clients.GenericClient().Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv) + Expect(err).NotTo(HaveOccurred(), "failed to get PV %s for PVC %s/%s", pvc.Spec.VolumeName, namespace, pvcName) + + return nodeFromPV(&pv) +} + +func nodeFromPV(pv *corev1.PersistentVolume) string { + GinkgoHelper() + + if pv.Spec.NodeAffinity == nil || pv.Spec.NodeAffinity.Required == nil { + return "" + } + + var candidates []string + for _, term := range pv.Spec.NodeAffinity.Required.NodeSelectorTerms { + for _, expr := range term.MatchExpressions { + if expr.Key != "topology.sds-local-volume-csi/node" && expr.Key != hostnameNodeSelectorKey { + continue + } + candidates = append(candidates, expr.Values...) + } + } + if len(candidates) == 0 { + return "" + } + + sort.Strings(candidates) + return candidates[0] +} + +// setupProject creates a non-isolated Deckhouse Project, waits until it is deployed and +// switches the framework to operate inside the project's namespace. The project (and +// therefore its namespace and every resource it contains) is removed during cleanup. +// +// The project uses the "NotRestricted" network policy: these tests boot VirtualMachines +// whose guests need outbound network access (cloud-init installs the qemu-guest-agent over +// the network), which the "Isolated" policy would block, leaving the guest agent forever +// not ready. Network-isolation behaviour is covered separately by the ImporterNetworkPolicy +// spec. +func setupProject(ctx context.Context, f *framework.Framework, prefix string) { + GinkgoHelper() + + project := object.NewNonIsolatedProject(prefix, framework.NamespaceBasePrefix) + + By("Creating a Project", func() { + err := f.CreateWithDeferredDeletion(ctx, project) + Expect(err).NotTo(HaveOccurred()) + + util.UntilObjectState(ctx, "Deployed", framework.ShortTimeout, project) + }) + + f.SetProjectNamespace(project.Name) + DeferCleanup(func() { + pinnedScenarioNodes.Delete(project.Name) + }) +} + +// startVirtualDisk starts the VirtualDisk observer with the standard invariants and +// creates vd, WITHOUT waiting for it to become Ready. The returned observer keeps +// enforcing the invariants until cleanup. Waiting for readiness is left to the caller +// because, on WaitForFirstConsumer storage classes, a VirtualDisk only provisions once a +// consumer (a VirtualMachine) is scheduled. +func startVirtualDisk(ctx context.Context, f *framework.Framework, vd *v1alpha2.VirtualDisk, opts ...progressWaitOption) vdobs.Observer { + GinkgoHelper() + + var o progressWaitOptions + for _, fn := range opts { + fn(&o) + } + + obs := vdobs.StartObserver(ctx, f, vd) + obs.Never(vdobs.BeFailed()) + obs.Always(vdobs.BeStorageClassReady()) + obs.Always(vdobs.BeDataSourceReady()) + obs.Always(vdobs.HaveValidPhaseTransitions()) + obs.Always(vdobs.HaveValidProgress(virtualDiskProgressExpectations(vd, o), progressUpdateInterval, progressBoundaryBudget)) + + By("Creating VirtualDisk", func() { + err := f.CreateWithDeferredDeletion(ctx, vd) + Expect(err).NotTo(HaveOccurred()) + }) + + return obs +} + +func virtualDiskProgressExpectations(vd *v1alpha2.VirtualDisk, o progressWaitOptions) vdobs.ProgressExpectations { + if o.skipStreamingProgress || o.skipDiskStreamingProgress { + return vdobs.ProgressExpectations{ + RequireZero: true, + RequireHundred: true, + } + } + if isVirtualDiskFromCVI(vd) { + return vdobs.ProgressExpectations{ + RequireZero: true, + RequireHundred: true, + } + } + if o.progressCoverage == progressCoverageIntermediate { + return vdobs.ProgressExpectations{ + RequireZero: true, + RequireIntermediateExceptFifty: true, + RequireHundred: true, + } + } + return streamedVirtualDiskProgress() +} + +func isVirtualDiskFromCVI(vd *v1alpha2.VirtualDisk) bool { + return vd.Spec.DataSource != nil && + vd.Spec.DataSource.ObjectRef != nil && + vd.Spec.DataSource.ObjectRef.Kind == v1alpha2.VirtualDiskObjectRefKindClusterVirtualImage +} + +func streamedVirtualDiskProgress() vdobs.ProgressExpectations { + return vdobs.ProgressExpectations{ + RequireZero: true, + RequireBetweenZeroAndFifty: true, + RequireBetweenFiftyAndHundred: true, + RequireIntermediateExceptFifty: true, + RequireHundred: true, + } +} + +// createVirtualDiskAndWait provisions vd and waits until it becomes Ready. It must only +// be used for disks that provision without a VirtualMachine (e.g. an Upload disk, whose +// uploader Pod is the consumer); on WaitForFirstConsumer storage classes a disk without +// a consumer never becomes Ready - use createVirtualDiskAndRunVM instead. +func createVirtualDiskAndWait(ctx context.Context, f *framework.Framework, vd *v1alpha2.VirtualDisk) { + GinkgoHelper() + + obs := startVirtualDisk(ctx, f, vd) + err := obs.WaitFor(vdobs.BeReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) +} + +// observedDisk pairs a VirtualDisk with the observer watching its lifecycle, as +// returned by startVirtualDisk. +type observedDisk struct { + vd *v1alpha2.VirtualDisk + obs vdobs.Observer +} + +// virtualDiskNoun returns the singular or plural "VirtualDisk(s)" noun for use +// in step messages, depending on how many disks are involved. +func virtualDiskNoun(n int) string { + if n == 1 { + return "VirtualDisk" + } + return "VirtualDisks" +} + +// createVirtualDiskAndRunVM provisions vd by booting a VirtualMachine from it (see +// runVirtualMachineFromDisks for the exact lifecycle). The VM is the disk's first +// consumer, so this works on both Immediate and WaitForFirstConsumer storage classes. +func createVirtualDiskAndRunVM(ctx context.Context, f *framework.Framework, vd *v1alpha2.VirtualDisk, opts ...progressWaitOption) { + GinkgoHelper() + + obs := startVirtualDisk(ctx, f, vd, opts...) + runVirtualMachineFromDisks(ctx, f, observedDisk{vd: vd, obs: obs}) +} + +// runVirtualMachineFromDisks drives the disk/VM lifecycle for the given disks, which the +// caller has already created via startVirtualDisk (the first disk is the boot disk): +// +// 1. (the disks are already created by the caller); +// 2. wait every disk to be Ready or waiting for a consumer (WaitForFirstConsumer); +// 3. create the VirtualMachine that consumes the disks; +// 4. wait every disk to become Ready (a WaitForFirstConsumer disk provisions only once +// the VirtualMachine is scheduled as its consumer); +// 5. wait the VirtualMachine to be Running; +// 6. wait the VirtualMachine guest agent to be ready. +func runVirtualMachineFromDisks(ctx context.Context, f *framework.Framework, disks ...observedDisk) *v1alpha2.VirtualMachine { + GinkgoHelper() + + noun := virtualDiskNoun(len(disks)) + + By(fmt.Sprintf("Waiting for the %s to settle before creating the VirtualMachine", noun), func() { + for _, d := range disks { + // A WaitForFirstConsumer disk must park in WaitForFirstConsumer (it provisions + // only once the VM consumer is scheduled); an Immediate disk must become Ready. + err := d.obs.WaitFor(expectedDiskPhaseBeforeVM(ctx, f, d.vd), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + } + }) + + vds := make([]*v1alpha2.VirtualDisk, len(disks)) + for i := range disks { + vds[i] = disks[i].vd + } + + vmOpts := []vmbuilder.Option{ + vmbuilder.WithDisks(vds...), + } + if node, ok := scenarioNode(f); ok { + // TODO: remove this test-level pin once local PVC/snapshot sources and + // target VMs are guaranteed to use the same node by the controllers. + // Without it, a source PV can be bound to one node while the target VM + // and its hotplug pod try to start on another node. + vmOpts = append(vmOpts, vmbuilder.WithNodeSelector(map[string]string{ + hostnameNodeSelectorKey: node, + })) + } + vm := object.NewMinimalVM("vm-from-disk-", f.Namespace().Name, vmOpts...) + + By(fmt.Sprintf("Creating VirtualMachine from the %s", noun), func() { + err := f.CreateWithDeferredDeletion(ctx, vm) + Expect(err).NotTo(HaveOccurred()) + }) + + // Start the observer only after Create: the VM uses a generated name, so its + // name is unknown (and the observer cannot match its watch events) until the + // API server assigns it during Create. + vmObs := vmobs.StartObserver(ctx, f, vm) + vmObs.Never(vmobs.BeFailed()) + // Fail fast instead of blocking on the guest-agent wait: a VM that reports + // NoBootableDevice will never boot an OS and never bring up its agent. + vmObs.Never(vmobs.HaveNoBootableDevice()) + + By(fmt.Sprintf("Waiting for the %s to be Ready", noun), func() { + for _, d := range disks { + err := d.obs.WaitFor(vdobs.BeReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + + err = f.Clients.GenericClient().Get(ctx, crclient.ObjectKeyFromObject(d.vd), d.vd) + Expect(err).NotTo(HaveOccurred()) + expectVirtualDiskStorageMode(ctx, f, d.vd) + } + }) + + By("Waiting for the VirtualMachine to be Running", func() { + err := vmObs.WaitFor(vmobs.BeRunning(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Waiting for the guest agent to be ready", func() { + err := vmObs.WaitFor(vmobs.BeAgentReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + node, pinned := scenarioNode(f) + if pinned { + err := f.Clients.GenericClient().Get(ctx, crclient.ObjectKeyFromObject(vm), vm) + Expect(err).NotTo(HaveOccurred()) + Expect(vm.Status.Node).To(Equal(node), "VirtualMachine must run on the scenario source node") + + for _, d := range disks { + rememberVirtualDiskNode(ctx, f, d.vd) + } + } + + return vm +} + +func doRetriableUploadAttempt(url, filePath string) error { + const maxAttempts = 12 + const retryDelay = 5 * time.Second + + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + err := doVirtualDiskUploadAttempt(url, filePath) + if err == nil { + return nil + } + if !isRetriableUploadError(err) { + return err + } + + lastErr = err + time.Sleep(retryDelay) + } + + return fmt.Errorf("upload failed after %d attempts: %w", maxAttempts, lastErr) +} + +func doVirtualDiskUploadAttempt(url, filePath string) error { + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open %s: %w", filePath, err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) { + Expect(closeErr).NotTo(HaveOccurred(), "Failed to close file %s", filePath) + } + }() + + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to stat %s: %w", filePath, err) + } + if stat.Size() == 0 { + return fmt.Errorf("file %s is empty", filePath) + } + + req, err := http.NewRequest(http.MethodPut, url, file) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + req.ContentLength = stat.Size() + + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) { + Expect(closeErr).NotTo(HaveOccurred(), "Failed to close response body") + } + }() + + return handleUploadResponse(resp) +} + +func isRetriableUploadError(err error) bool { + message := err.Error() + return !strings.Contains(message, "upload failed with status ") || + strings.Contains(message, "upload failed with status 5") +} + +// downloadImageToTempFile downloads url into a temporary file and returns its path. +// The caller is responsible for removing the file when finished. +func downloadImageToTempFile(url string) (string, error) { + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + resp, err := httpClient.Get(url) + if err != nil { + return "", fmt.Errorf("download %q: %w", url, err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) { + Expect(closeErr).NotTo(HaveOccurred(), "failed to close response body") + } + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("download %q: unexpected status %d", url, resp.StatusCode) + } + + tmpFile, err := os.CreateTemp("", filepath.Base(url)+"-*") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + closed := false + defer func() { + if closed { + return + } + if closeErr := tmpFile.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) { + Expect(closeErr).NotTo(HaveOccurred(), "failed to close temp file") + } + }() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + return "", fmt.Errorf("copy to temp file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + return "", fmt.Errorf("close temp file: %w", err) + } + closed = true + + return tmpFile.Name(), nil +} + +// uploaderIngressNginxNamespaceLabel is the namespace label used to match the +// Deckhouse ingress-nginx controller namespace. +const uploaderIngressNginxNamespaceLabel = "module" + +// uploaderIngressNginxNamespaceLabelValue is the value of the namespace label +// for the Deckhouse ingress-nginx controller namespace (d8-ingress-nginx). +const uploaderIngressNginxNamespaceLabelValue = "ingress-nginx" + +// controllerNamespaceLabel / controllerNamespaceLabelValue match the namespace +// where the virtualization-controller runs (d8-virtualization). +const ( + controllerNamespaceLabel = "kubernetes.io/metadata.name" + controllerNamespaceLabelValue = "d8-virtualization" +) + +// allowIngressToUploaderNetworkPolicy patches the NetworkPolicy created by the +// virtualization-controller for the uploader pod owned by vd, so that traffic +// from the namespaces the upload flow depends on is allowed to reach the +// uploader pod: +// +// - d8-ingress-nginx (label "module=ingress-nginx"): without it external +// uploads via the Ingress URL fail with a 504 Gateway Time-out. +// - d8-virtualization (the virtualization-controller namespace): the +// controller scrapes the uploader's progress metrics over the pod IP. As +// soon as any ingress rule is present on the pod, Cilium starts enforcing +// ingress and would otherwise drop the controller's scrape, which makes the +// reported upload progress stay stuck at 0% and jump straight to 50% only +// when the uploader pod completes. Allowing d8-virtualization keeps the +// live progress flowing (0% -> 50%). +func allowIngressToUploaderNetworkPolicy(ctx context.Context, f *framework.Framework, namespace string, ownerUID types.UID) error { + var policies netv1.NetworkPolicyList + if err := f.Clients.GenericClient().List(ctx, &policies, crclient.InNamespace(namespace)); err != nil { + return fmt.Errorf("list network policies in %q: %w", namespace, err) + } + + requiredPeers := []map[string]string{ + {uploaderIngressNginxNamespaceLabel: uploaderIngressNginxNamespaceLabelValue}, + {controllerNamespaceLabel: controllerNamespaceLabelValue}, + } + + var patched int + for i := range policies.Items { + np := &policies.Items[i] + if !isOwnedByUID(np.OwnerReferences, ownerUID) { + continue + } + + var changed bool + for _, labels := range requiredPeers { + if hasNamespaceSelectorPeer(np.Spec.Ingress, labels) { + continue + } + if len(np.Spec.Ingress) == 0 { + np.Spec.Ingress = []netv1.NetworkPolicyIngressRule{{}} + } + np.Spec.Ingress[0].From = append(np.Spec.Ingress[0].From, netv1.NetworkPolicyPeer{ + NamespaceSelector: &metav1.LabelSelector{MatchLabels: labels}, + }) + changed = true + } + + if changed { + if err := f.Clients.GenericClient().Update(ctx, np); err != nil { + return fmt.Errorf("update network policy %q: %w", np.Name, err) + } + } + patched++ + } + + if patched == 0 { + return fmt.Errorf("no NetworkPolicy owned by UID %q found in %q", ownerUID, namespace) + } + return nil +} + +// expectVirtualDiskStorageMode verifies that a Ready VirtualDisk target PVC +// matches the volume mode resolved for the disk's StorageClass. +func expectVirtualDiskStorageMode(ctx context.Context, f *framework.Framework, vd *v1alpha2.VirtualDisk) { + GinkgoHelper() + + Expect(vd.Status.Target.PersistentVolumeClaim).NotTo(BeEmpty()) + + pvc := &corev1.PersistentVolumeClaim{} + err := f.Clients.GenericClient().Get(ctx, types.NamespacedName{ + Name: vd.Status.Target.PersistentVolumeClaim, + Namespace: vd.Namespace, + }, pvc) + Expect(err).NotTo(HaveOccurred(), "failed to get target PVC for VirtualDisk %q", vd.Name) + Expect(pvc.Spec.VolumeMode).NotTo(BeNil()) + Expect(*pvc.Spec.VolumeMode).To(Equal(storageClassVolumeMode(ctx, f, vd.Status.StorageClassName))) +} + +func isOwnedByUID(refs []metav1.OwnerReference, uid types.UID) bool { + for _, ref := range refs { + if ref.UID == uid { + return true + } + } + return false +} + +func hasNamespaceSelectorPeer(rules []netv1.NetworkPolicyIngressRule, labels map[string]string) bool { + for _, rule := range rules { + for _, from := range rule.From { + if from.NamespaceSelector == nil { + continue + } + if equalLabels(from.NamespaceSelector.MatchLabels, labels) { + return true + } + } + } + return false +} + +func equalLabels(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} diff --git a/test/e2e/blockdevice/virtual_disk_format.go b/test/e2e/blockdevice/virtual_disk_format.go new file mode 100644 index 0000000000..ce6eb1c03d --- /dev/null +++ b/test/e2e/blockdevice/virtual_disk_format.go @@ -0,0 +1,80 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blockdevice + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/utils/ptr" + + vdbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vd" + vibuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vi" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + "github.com/deckhouse/virtualization/test/e2e/internal/precheck" +) + +// VirtualDiskFormat verifies how disk image formats are handled when the source is a +// precreated ClusterVirtualImage: +// - a qcow2 image can back a VirtualDisk, and a VirtualMachine boots from that disk; +// - an ISO image cannot back a VirtualDisk, so it is consumed as a VirtualImage and a +// VirtualMachine boots it as a CD-ROM instead. +// +// The qcow2 spec provisions its main VirtualDisk on the WFFC StorageClass, so the precheck +// label is declared on the Describe (the spec-label validator only reads container-hierarchy +// labels, not leaf It labels). +var _ = Describe("VirtualDiskFormat", Label(precheck.PrecheckWFFCStorageClass), func() { + var ( + f *framework.Framework + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + f = framework.NewFramework("") + f.Before() + DeferCleanup(f.After) + setupProject(ctx, f, "vd-format") + }) + + It("provisions a VirtualDisk from a qcow2 ClusterVirtualImage and runs a VirtualMachine with a ready agent", func() { + // The disk under test is the scenario's main resource, so it lives on the WFFC + // storage class. + vd := object.NewVDFromCVI("vd-qcow2", f.Namespace().Name, object.PrecreatedCVIAlpineBIOS, + vdbuilder.WithStorageClass(wffcStorageClass()), + vdbuilder.WithSize(ptr.To(resource.MustParse("350Mi")))) + + createVirtualDiskAndRunVM(ctx, f, vd) + }) + + It("runs a VirtualMachine from an iso ClusterVirtualImage through a VirtualImage", func() { + // A VirtualDisk cannot be created from an ISO image, so the ISO is consumed as a + // VirtualImage and the VirtualMachine boots it as a CD-ROM. A fixed name is used + // (the spec has its own Project) so the observer, which is started before the + // VirtualImage is created, watches the right object instead of an empty name. + vi := newVirtualImageOnDVCR("vi-iso", + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVIUbuntuISO), + ) + + createVirtualImageAndWait(ctx, f, vi) + + runVirtualMachineFromImageUntilRunning(ctx, f, vi) + }) +}) diff --git a/test/e2e/blockdevice/virtual_image_creation.go b/test/e2e/blockdevice/virtual_image_creation.go index e0bc120762..a5a39bafae 100644 --- a/test/e2e/blockdevice/virtual_image_creation.go +++ b/test/e2e/blockdevice/virtual_image_creation.go @@ -1,11 +1,11 @@ /* -Copyright 2025 Flant JSC +Copyright 2026 Flant JSC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -18,256 +18,561 @@ package blockdevice import ( "context" - "fmt" + "errors" + "os" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "k8s.io/apimachinery/pkg/api/resource" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client" + crclient "sigs.k8s.io/controller-runtime/pkg/client" - cvibuilder "github.com/deckhouse/virtualization-controller/pkg/builder/cvi" vdbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vd" vdsnapshotbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vdsnapshot" vibuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vi" - vmbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vm" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/test/e2e/internal/framework" "github.com/deckhouse/virtualization/test/e2e/internal/object" + vdobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vd" + vdsnapshotobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vdsnapshot" + viobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vi" "github.com/deckhouse/virtualization/test/e2e/internal/precheck" - "github.com/deckhouse/virtualization/test/e2e/internal/util" ) -var _ = Describe("VirtualImageCreation", Label(precheck.PrecheckSnapshot), func() { +var _ = Describe("VirtualImageCreation", Label( + precheck.PrecheckDefaultStorageClass, + precheck.PrecheckSnapshot, +), func() { var ( f *framework.Framework ctx context.Context + + scPtr *string ) - BeforeEach(func() { + // setup provisions a fresh framework, a dedicated Project and the storage class + // pointers. It is invoked from a BeforeEach for independent specs (each spec gets + // its own Project, so the DVCR and PVC specs can run in parallel) and from a + // BeforeAll for specs that share a common dependency created once for the whole + // Ordered container. + setup := func() { ctx = context.Background() - f = framework.NewFramework("vi-creation") - sc := framework.GetConfig().StorageClass.TemplateStorageClass - if sc != nil && sc.Provisioner == framework.NFS { - Skip("VirtualImages on PVC only work with block storage classes, skipping NFS") - } - + f = framework.NewFramework("") f.Before() DeferCleanup(f.After) + setupProject(ctx, f, "vi-creation") + + scPtr = defaultStorageClass() + } + + // The DVCR and PVC specs below do not share an in-cluster dependency, so each gets + // its own Project via BeforeEach and the two specs can run in parallel. + + Context("from HTTP data source", func() { + BeforeEach(setup) + + It("provisions a VirtualImage on DVCR", func() { + vi := newVirtualImageOnDVCR("vi-http", + vibuilder.WithDataSourceHTTP(object.ImageURLAlpineBIOS, nil, nil), + ) + createVirtualImageAndRunVM(ctx, f, vi) + }) + + It("provisions a VirtualImage on PVC", func() { + vi := newVirtualImageOnPVC("vi-pvc-http", scPtr, + vibuilder.WithDataSourceHTTP(object.ImageURLAlpineBIOS, nil, nil), + ) + createVirtualImageAndRunVM(ctx, f, vi) + }) }) - It("verifies the images are created successfully", func() { - const cviPrefix = "v12-e2e" - var ( - vd *v1alpha2.VirtualDisk - vdSnapshot *v1alpha2.VirtualDiskSnapshot - vis []*v1alpha2.VirtualImage - cvis []*v1alpha2.ClusterVirtualImage - - baseCvis []*v1alpha2.ClusterVirtualImage - baseVis []*v1alpha2.VirtualImage - ) - - By("Creating VirtualDisk", func() { - vd = vdbuilder.New( - vdbuilder.WithGenerateName("vd-"), - vdbuilder.WithNamespace(f.Namespace().Name), - vdbuilder.WithSize(ptr.To(resource.MustParse("350Mi"))), - vdbuilder.WithDataSourceHTTP( - &v1alpha2.DataSourceHTTP{ - URL: object.ImageURLAlpineUEFI, - }, - ), + Context("from ContainerImage (registry) data source", func() { + BeforeEach(setup) + + It("provisions a VirtualImage on DVCR", func() { + vi := newVirtualImageOnDVCR("vi-registry", + vibuilder.WithDataSourceContainerImage(object.ImageURLContainerImage, v1alpha2.ImagePullSecretName{}, nil), ) - err := f.CreateWithDeferredDeletion(ctx, vd) - Expect(err).NotTo(HaveOccurred()) - vm := object.NewMinimalVM("vm-", f.Namespace().Name, vmbuilder.WithBlockDeviceRefs(v1alpha2.BlockDeviceSpecRef{ - Kind: v1alpha2.VirtualDiskKind, - Name: vd.Name, - })) - err = f.CreateWithDeferredDeletion(ctx, vm) - Expect(err).NotTo(HaveOccurred()) - util.UntilObjectPhase(ctx, string(v1alpha2.DiskReady), framework.LongTimeout, vd) - err = f.Delete(ctx, vm) - Expect(err).NotTo(HaveOccurred()) + createVirtualImageAndRunVM(ctx, f, vi) }) - By("Creating VirtualDiskSnapshot", func() { - vdSnapshot = vdsnapshotbuilder.New( - vdsnapshotbuilder.WithGenerateName("vdsnapshot-"), - vdsnapshotbuilder.WithNamespace(f.Namespace().Name), - vdsnapshotbuilder.WithVirtualDiskName(vd.Name), - vdsnapshotbuilder.WithRequiredConsistency(true), + It("provisions a VirtualImage on PVC", func() { + vi := newVirtualImageOnPVC("vi-pvc-registry", scPtr, + vibuilder.WithDataSourceContainerImage(object.ImageURLContainerImage, v1alpha2.ImagePullSecretName{}, nil), ) - err := f.CreateWithDeferredDeletion(ctx, vdSnapshot) - Expect(err).NotTo(HaveOccurred()) - util.UntilObjectPhase(ctx, string(v1alpha2.VirtualDiskSnapshotPhaseReady), framework.ShortTimeout, vdSnapshot) + createVirtualImageAndRunVM(ctx, f, vi) }) + }) + + Context("from a ClusterVirtualImage", func() { + BeforeEach(setup) - By("Generating base cvis", func() { - baseCvis = append(baseCvis, object.NewGenerateContainerImageCVI(fmt.Sprintf("%s-cvi-ci-", cviPrefix))) - baseCvis = append(baseCvis, cvibuilder.New( - cvibuilder.WithGenerateName(fmt.Sprintf("%s-cvi-http-", cviPrefix)), - cvibuilder.WithDataSourceHTTP( - object.ImageURLAlpineUEFI, nil, nil, - ), - )) - baseCvis = append(baseCvis, cvibuilder.New( - cvibuilder.WithGenerateName(fmt.Sprintf("%s-cvi-from-vd-", cviPrefix)), - cvibuilder.WithDataSourceObjectRef(v1alpha2.ClusterVirtualImageObjectRefKindVirtualDisk, vd.Name, f.Namespace().Name), - )) - baseCvis = append(baseCvis, cvibuilder.New( - cvibuilder.WithGenerateName(fmt.Sprintf("%s-cvi-from-vds-", cviPrefix)), - cvibuilder.WithDataSourceObjectRef(v1alpha2.ClusterVirtualImageObjectRefKindVirtualDiskSnapshot, vdSnapshot.Name, f.Namespace().Name), - )) + It("provisions a VirtualImage on DVCR", func() { + vi := newVirtualImageOnDVCR("vi-from-cvi", + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + ) + createVirtualImageAndRunVM(ctx, f, vi) }) - By("Generating base vis on dvcr", func() { - baseVis = append(baseVis, object.NewGeneratedContainerImageVI("vi-ci-", f.Namespace().Name)) - baseVis = append(baseVis, vibuilder.New( - vibuilder.WithGenerateName("vi-http-"), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), - vibuilder.WithDataSourceHTTP( - object.ImageURLAlpineUEFI, nil, nil, - ), - )) - baseVis = append(baseVis, vibuilder.New( - vibuilder.WithGenerateName("vi-from-vd-"), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), + It("provisions a VirtualImage on PVC", func() { + vi := newVirtualImageOnPVC("vi-pvc-from-cvi", scPtr, + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + ) + createVirtualImageAndRunVM(ctx, f, vi, withIntermediateProgress()) + }) + }) + + // The specs below share an in-cluster (or downloaded) dependency, so it is created + // once in a BeforeAll and reused by both the DVCR and PVC specs of an Ordered + // container. Different Ordered containers still run in parallel across processes. + + Context("from Upload data source", Ordered, func() { + var uploadFilePath string + + BeforeAll(func() { + setup() + + By("Downloading source image to upload", func() { + var err error + uploadFilePath, err = downloadImageToTempFile(object.ImageURLAlpineBIOS) + Expect(err).NotTo(HaveOccurred(), "failed to download upload source image") + DeferCleanup(func() { + removeErr := os.Remove(uploadFilePath) + Expect(removeErr == nil || errors.Is(removeErr, os.ErrNotExist)).To(BeTrue(), + "failed to remove upload source file %q: %v", uploadFilePath, removeErr) + }) + }) + }) + + It("provisions a VirtualImage on DVCR", func() { + vi := newVirtualImageOnDVCR("vi-upload", + vibuilder.WithDatasource(v1alpha2.VirtualImageDataSource{ + Type: v1alpha2.DataSourceTypeUpload, + }), + ) + uploadVirtualImageAndWait(ctx, f, vi, uploadFilePath) + runVirtualMachineFromImageDisk(ctx, f, vi) + }) + + It("provisions a VirtualImage on PVC", func() { + vi := newVirtualImageOnPVC("vi-pvc-upload", scPtr, + vibuilder.WithDatasource(v1alpha2.VirtualImageDataSource{ + Type: v1alpha2.DataSourceTypeUpload, + }), + ) + uploadVirtualImageAndWait(ctx, f, vi, uploadFilePath) + runVirtualMachineFromImageDisk(ctx, f, vi) + }) + }) + + Context("from a VirtualDisk", Ordered, func() { + var vd *v1alpha2.VirtualDisk + + BeforeAll(func() { + setup() + vd = createSourceVirtualDiskAndWait(ctx, f, "vd-source-for-vi", scPtr) + }) + + It("provisions a VirtualImage on DVCR", func() { + vi := newVirtualImageOnDVCR("vi-from-vd", vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDisk, vd.Name), - )) - baseVis = append(baseVis, vibuilder.New( - vibuilder.WithGenerateName("vi-from-vds-"), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), - vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDiskSnapshot, vdSnapshot.Name), - )) + ) + createVirtualImageAndRunVM(ctx, f, vi) }) - By("Generating base vis on pvc", func() { - baseVis = append(baseVis, object.NewGeneratedContainerImageVI("vi-pvc-ci-", f.Namespace().Name, vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim))) - baseVis = append(baseVis, vibuilder.New( - vibuilder.WithGenerateName("vi-http-"), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), - vibuilder.WithDataSourceHTTP( - object.ImageURLAlpineUEFI, nil, nil, - ), - )) - baseVis = append(baseVis, vibuilder.New( - vibuilder.WithGenerateName("vi-pvc-from-vd-"), - vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), - vibuilder.WithNamespace(f.Namespace().Name), + It("provisions a VirtualImage on PVC", func() { + vi := newVirtualImageOnPVC("vi-pvc-from-vd", scPtr, vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDisk, vd.Name), - )) - baseVis = append(baseVis, vibuilder.New( - vibuilder.WithGenerateName("vi-pvc-from-vds-"), - vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDiskSnapshot, vdSnapshot.Name), - )) + ) + createVirtualImageAndRunVM(ctx, f, vi, withoutStreamingProgress()) }) + }) - By("Creating base images", func() { - for _, cvi := range baseCvis { - err := f.CreateWithDeferredDeletion(ctx, cvi) - Expect(err).NotTo(HaveOccurred()) - } - for _, vi := range baseVis { - err := f.CreateWithDeferredDeletion(ctx, vi) - Expect(err).NotTo(HaveOccurred()) - } + Context("from a VirtualImage on DVCR", Ordered, func() { + var baseVI *v1alpha2.VirtualImage + + BeforeAll(func() { + setup() + baseVI = newVirtualImageOnDVCR("vi-source-dvcr", + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + ) + createVirtualImageAndWait(ctx, f, baseVI) }) - By("Generating cvis from base cvis", func() { - for _, baseCvi := range baseCvis { - cvis = append(cvis, cvibuilder.New( - cvibuilder.WithName(fmt.Sprintf("%s-cvi-from-%s", cviPrefix, baseCvi.Name)), - cvibuilder.WithDataSourceObjectRef(v1alpha2.ClusterVirtualImageObjectRefKindClusterVirtualImage, baseCvi.Name, ""), - )) - } + It("provisions a VirtualImage on DVCR", func() { + vi := newVirtualImageOnDVCR("vi-from-vi-dvcr", + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualImage, baseVI.Name), + ) + createVirtualImageAndRunVM(ctx, f, vi, withMinimalProgress()) }) - By("Generating cvis from base vis", func() { - for _, baseVi := range baseVis { - cvis = append(cvis, cvibuilder.New( - cvibuilder.WithName(fmt.Sprintf("%s-cvi-from-%s", cviPrefix, baseVi.Name)), - cvibuilder.WithDataSourceObjectRef(v1alpha2.ClusterVirtualImageObjectRefKindVirtualImage, baseVi.Name, baseVi.Namespace), - )) - } + It("provisions a VirtualImage on PVC", func() { + vi := newVirtualImageOnPVC("vi-pvc-from-vi-dvcr", scPtr, + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualImage, baseVI.Name), + ) + createVirtualImageAndRunVM(ctx, f, vi, withIntermediateProgress()) }) + }) - By("Generating dvcr vis from base cvis", func() { - for _, baseCvi := range baseCvis { - vis = append(vis, vibuilder.New( - vibuilder.WithName(fmt.Sprintf("vi-from-%s", baseCvi.Name)), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, baseCvi.Name), - vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), - )) - } + Context("from a VirtualImage on PVC", Ordered, func() { + var baseVI *v1alpha2.VirtualImage + + BeforeAll(func() { + setup() + baseVI = newVirtualImageOnPVC("vi-source-pvc", scPtr, + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + ) + createVirtualImageAndWait(ctx, f, baseVI) }) - By("Generating dvcr vis from base vis", func() { - for _, baseVi := range baseVis { - vis = append(vis, vibuilder.New( - vibuilder.WithName(fmt.Sprintf("vi-from-%s", baseVi.Name)), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), - vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualImage, baseVi.Name), - )) - } + It("provisions a VirtualImage on DVCR", func() { + vi := newVirtualImageOnDVCR("vi-from-vi-pvc", + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualImage, baseVI.Name), + ) + createVirtualImageAndRunVM(ctx, f, vi) }) - By("Generating pvc vis from base cvis", func() { - for _, baseCvi := range baseCvis { - vis = append(vis, vibuilder.New( - vibuilder.WithName(fmt.Sprintf("vi-pvc-from-%s", baseCvi.Name)), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, baseCvi.Name), - vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), - )) - } + It("provisions a VirtualImage on PVC", func() { + vi := newVirtualImageOnPVC("vi-pvc-from-vi-pvc", scPtr, + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualImage, baseVI.Name), + ) + // PVC-to-PVC snapshot population does not stream importer progress. + createVirtualImageAndRunVM(ctx, f, vi, withoutStreamingProgress()) }) + }) + + // TODO(sc): disabled while VirtualImageCreation is constrained to a single + // default StorageClass. Re-enable when different-StorageClass scenarios are + // needed again. + /* + Context("on PVC from a source on a different storage class of the same CSI driver", func() { + BeforeEach(setup) + + It("provisions a VirtualImage from a VirtualDisk", func() { + vd := createSourceVirtualDiskAndWait(ctx, f, "vd-source-for-vi-other-sc", scPtr) + + vi := newVirtualImageOnPVC("vi-pvc-from-vd-other-sc", scPtr, + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDisk, vd.Name), + ) + createVirtualImageAndWait(ctx, f, vi, withoutStreamingProgress()) + }) - By("Generating pvc vis from base vis", func() { - for _, baseVi := range baseVis { - vis = append(vis, vibuilder.New( - vibuilder.WithName(fmt.Sprintf("vi-pvc-from-%s", baseVi.Name)), - vibuilder.WithNamespace(f.Namespace().Name), - vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim), - vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualImage, baseVi.Name), - )) - } + It("provisions a VirtualImage from a VirtualImage", func() { + baseVI := newVirtualImageOnPVC("vi-source-pvc-other-sc", scPtr, + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + ) + createVirtualImageAndWait(ctx, f, baseVI) + + vi := newVirtualImageOnPVC("vi-pvc-from-vi-other-sc", scPtr, + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualImage, baseVI.Name), + ) + createVirtualImageAndWait(ctx, f, vi, withoutStreamingProgress()) + }) }) + */ + + Context("from a VirtualDiskSnapshot", Ordered, Label(precheck.PrecheckSnapshot), func() { + var vdSnapshot *v1alpha2.VirtualDiskSnapshot + + BeforeAll(func() { + setup() + vd := createSourceVirtualDiskAndWait(ctx, f, "vd-source-for-vi-snapshot", scPtr) + + vdSnapshot = vdsnapshotbuilder.New( + vdsnapshotbuilder.WithName("vdsnapshot-source-for-vi"), + vdsnapshotbuilder.WithNamespace(f.Namespace().Name), + vdsnapshotbuilder.WithVirtualDiskName(vd.Name), + vdsnapshotbuilder.WithRequiredConsistency(true), + ) - By("Creating images", func() { - for _, vi := range vis { - err := f.CreateWithDeferredDeletion(ctx, vi) + snapObs := vdsnapshotobs.StartObserver(ctx, f, vdSnapshot) + By("Creating VirtualDiskSnapshot", func() { + err := f.CreateWithDeferredDeletion(ctx, vdSnapshot) Expect(err).NotTo(HaveOccurred()) - } - for _, cvi := range cvis { - err := f.CreateWithDeferredDeletion(ctx, cvi) + err = snapObs.WaitFor(vdsnapshotobs.BeReady(), framework.LongTimeout) Expect(err).NotTo(HaveOccurred()) - } + }) }) - By("Verifying that images are ready", func() { - // Should check base images too - vis = append(baseVis, vis...) - cvis = append(baseCvis, cvis...) - - var objects []client.Object - for _, vi := range vis { - objects = append(objects, vi) - } - for _, cvi := range cvis { - objects = append(objects, cvi) - } - util.UntilObjectPhase(ctx, string(v1alpha2.ImageReady), framework.LongTimeout, objects...) + It("provisions a VirtualImage on DVCR", func() { + vi := newVirtualImageOnDVCR("vi-from-vdsnapshot", + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDiskSnapshot, vdSnapshot.Name), + ) + createVirtualImageAndRunVM(ctx, f, vi) + }) + + It("provisions a VirtualImage on PVC", func() { + vi := newVirtualImageOnPVC("vi-pvc-from-vdsnapshot", scPtr, + vibuilder.WithDataSourceObjectRef(v1alpha2.VirtualImageObjectRefKindVirtualDiskSnapshot, vdSnapshot.Name), + ) + createVirtualImageAndRunVM(ctx, f, vi, withoutStreamingProgress()) }) }) }) + +func newVirtualImageOnDVCR(name string, opts ...vibuilder.Option) *v1alpha2.VirtualImage { + baseOpts := []vibuilder.Option{ + vibuilder.WithName(name), + vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), + } + baseOpts = append(baseOpts, opts...) + return vibuilder.New(baseOpts...) +} + +func newVirtualImageOnPVC(name string, sc *string, opts ...vibuilder.Option) *v1alpha2.VirtualImage { + vi := newVirtualImageOnDVCR(name, + append([]vibuilder.Option{vibuilder.WithStorage(v1alpha2.StoragePersistentVolumeClaim)}, opts...)..., + ) + vi.Spec.PersistentVolumeClaim.StorageClass = sc + return vi +} + +// progressWaitOptions tunes the progress coverage expected while waiting for a +// VirtualImage or VirtualDisk to become Ready. +type progressWaitOptions struct { + // progressCoverage selects the expected set of observed progress values. + progressCoverage progressCoverage + // skipStreamingProgress selects the minimal progress coverage (0% and + // 100%). It is used by snapshot-based PVC population, which does not report + // importer percentages. + skipStreamingProgress bool + // skipDiskStreamingProgress relaxes only the downstream VirtualDisk progress + // check used by createVirtualImageAndRunVM. A PVC-backed VirtualImage can be + // imported with streamed progress, while the disk created from it may then use + // snapshot-based PVC population with no importer pod to report intermediate + // percentages. + skipDiskStreamingProgress bool +} + +type progressWaitOption func(*progressWaitOptions) + +type progressCoverage int + +const ( + progressCoverageFull progressCoverage = iota + progressCoverageMinimal + progressCoverageIntermediate +) + +// withoutStreamingProgress selects minimal progress coverage for resources provisioned +// without an importer pod, such as snapshot-based PVC population. +func withoutStreamingProgress() progressWaitOption { + return func(o *progressWaitOptions) { + o.skipStreamingProgress = true + o.progressCoverage = progressCoverageMinimal + } +} + +func withoutDiskStreamingProgress() progressWaitOption { + return func(o *progressWaitOptions) { + o.skipDiskStreamingProgress = true + } +} + +func withMinimalProgress() progressWaitOption { + return func(o *progressWaitOptions) { + o.progressCoverage = progressCoverageMinimal + } +} + +func withIntermediateProgress() progressWaitOption { + return func(o *progressWaitOptions) { + o.progressCoverage = progressCoverageIntermediate + } +} + +func createVirtualImageAndWait(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage, opts ...progressWaitOption) { + GinkgoHelper() + + var o progressWaitOptions + for _, fn := range opts { + fn(&o) + } + + vi.Namespace = f.Namespace().Name + obs := viobs.StartObserver(ctx, f, vi) + obs.Never(viobs.BeFailed()) + obs.Always(viobs.HaveValidPhaseTransitions()) + obs.Always(viobs.HaveValidProgress(virtualImageProgressExpectations(vi, o), progressUpdateInterval, progressBoundaryBudget)) + obs.Always(viobs.HaveFormat(expectedVirtualImageFormat(ctx, f, vi))) + + By("Creating VirtualImage on "+virtualImageStorageName(vi), func() { + err := f.CreateWithDeferredDeletion(ctx, vi) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Waiting for the VirtualImage to be Ready", func() { + err := obs.WaitFor(viobs.BeReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + rememberVirtualImageNode(ctx, f, vi) +} + +func uploadVirtualImageAndWait(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage, uploadFilePath string) { + GinkgoHelper() + + vi.Namespace = f.Namespace().Name + obs := viobs.StartObserver(ctx, f, vi) + obs.Never(viobs.BeFailed()) + obs.Always(viobs.HaveValidPhaseTransitions()) + obs.Always(viobs.HaveValidProgress(virtualImageProgressExpectations(vi, progressWaitOptions{}), progressUpdateInterval, progressBoundaryBudget)) + obs.Always(viobs.HaveFormat(expectedVirtualImageFormat(ctx, f, vi))) + + By("Creating VirtualImage on "+virtualImageStorageName(vi), func() { + err := f.CreateWithDeferredDeletion(ctx, vi) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Waiting for the VirtualImage to expose upload URLs", func() { + err := obs.WaitFor(viobs.BeReadyForUserUpload(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Allowing ingress-nginx and the controller to reach the uploader pod (workaround)", func() { + err := allowIngressToUploaderNetworkPolicy(ctx, f, vi.Namespace, vi.UID) + Expect(err).NotTo(HaveOccurred(), "failed to patch uploader NetworkPolicy") + }) + + By("Uploading data to the VirtualImage", func() { + err := f.Clients.GenericClient().Get(ctx, crclient.ObjectKeyFromObject(vi), vi) + Expect(err).NotTo(HaveOccurred()) + Expect(vi.Status.ImageUploadURLs).NotTo(BeNil()) + Expect(vi.Status.ImageUploadURLs.External).NotTo(BeEmpty()) + + err = doRetriableUploadAttempt(vi.Status.ImageUploadURLs.External, uploadFilePath) + Expect(err).NotTo(HaveOccurred(), "upload should succeed") + }) + + By("Waiting for the VirtualImage to be Ready", func() { + err := obs.WaitFor(viobs.BeReady(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + rememberVirtualImageNode(ctx, f, vi) +} + +func virtualImageProgressExpectations(vi *v1alpha2.VirtualImage, o progressWaitOptions) viobs.ProgressExpectations { + if isVirtualImageFromCVI(vi) { + return minimalVirtualImageProgress() + } + if vi.Spec.Storage == v1alpha2.StorageContainerRegistry { + if o.progressCoverage == progressCoverageMinimal { + return minimalVirtualImageProgress() + } + return intermediateVirtualImageProgress() + } + switch o.progressCoverage { + case progressCoverageMinimal: + return minimalVirtualImageProgress() + case progressCoverageIntermediate: + return intermediateVirtualImageProgress() + default: + return viobs.ProgressExpectations{ + RequireZero: true, + RequireBetweenZeroAndFifty: true, + RequireBetweenFiftyAndHundred: true, + RequireIntermediateExceptFifty: true, + RequireHundred: true, + } + } +} + +func minimalVirtualImageProgress() viobs.ProgressExpectations { + return viobs.ProgressExpectations{ + RequireZero: true, + RequireHundred: true, + } +} + +func intermediateVirtualImageProgress() viobs.ProgressExpectations { + return viobs.ProgressExpectations{ + RequireZero: true, + RequireIntermediateExceptFifty: true, + RequireHundred: true, + } +} + +func isVirtualImageFromCVI(vi *v1alpha2.VirtualImage) bool { + return vi.Spec.DataSource.ObjectRef != nil && + vi.Spec.DataSource.ObjectRef.Kind == v1alpha2.VirtualImageObjectRefKindClusterVirtualImage +} + +// createVirtualImageAndRunVM provisions a (qcow2) VirtualImage, provisions a VirtualDisk +// from it, and boots a VirtualMachine from that disk. A VirtualImage cannot occupy a VM's +// first block-device slot, so booting always goes through a VirtualDisk; the VM is run +// until it is Running and its guest agent is ready. +func createVirtualImageAndRunVM(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage, opts ...progressWaitOption) { + GinkgoHelper() + + createVirtualImageAndWait(ctx, f, vi, opts...) + runVirtualMachineFromImageDisk(ctx, f, vi, opts...) +} + +// runVirtualMachineFromImageDisk provisions a VirtualDisk from the (Ready) VirtualImage +// and boots a VirtualMachine from that disk, waiting until the VM is Running and its guest +// agent is ready. +func runVirtualMachineFromImageDisk(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage, opts ...progressWaitOption) { + GinkgoHelper() + + if vi.Spec.Storage == v1alpha2.StoragePersistentVolumeClaim { + opts = append(opts, withoutDiskStreamingProgress()) + } else { + opts = append(opts, withIntermediateProgress()) + } + + // The disk that boots the VM is the scenario's main resource, so it uses the + // same default StorageClass as every other resource in this spec. + vd := object.NewVDFromVI("vd-from-"+vi.Name, f.Namespace().Name, vi, + vdbuilder.WithStorageClass(defaultStorageClass()), + ) + createVirtualDiskAndRunVM(ctx, f, vd, opts...) +} + +func createSourceVirtualDiskAndWait(ctx context.Context, f *framework.Framework, name string, sc *string) *v1alpha2.VirtualDisk { + GinkgoHelper() + + vd := vdbuilder.New( + vdbuilder.WithName(name), + vdbuilder.WithNamespace(f.Namespace().Name), + // Incidental source disk: provision from a precreated ClusterVirtualImage. + vdbuilder.WithDataSourceObjectRef(v1alpha2.VirtualDiskObjectRefKindClusterVirtualImage, object.PrecreatedCVIAlpineBIOS), + vdbuilder.WithStorageClass(sc), + ) + + obs := startVirtualDisk(ctx, f, vd, withoutStreamingProgress()) + vm := runVirtualMachineFromDisks(ctx, f, observedDisk{vd: vd, obs: obs}) + + By("Deleting the temporary VirtualMachine that provisioned the source VirtualDisk", func() { + err := f.Delete(ctx, vm) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Waiting for the source VirtualDisk to detach", func() { + err := obs.WaitFor(vdobs.BeDetached(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + rememberVirtualDiskNode(ctx, f, vd) + + return vd +} + +func defaultStorageClass() *string { + GinkgoHelper() + + sc := framework.GetConfig().StorageClass.TemplateStorageClass + Expect(sc).NotTo(BeNil(), "StorageClass not found: set STORAGE_CLASS_NAME or configure a default StorageClass") + + return &sc.Name +} + +func virtualImageStorageName(vi *v1alpha2.VirtualImage) string { + switch vi.Spec.Storage { + case v1alpha2.StorageContainerRegistry: + return "DVCR" + case v1alpha2.StoragePersistentVolumeClaim: + return "PVC" + default: + return string(vi.Spec.Storage) + } +} diff --git a/test/e2e/blockdevice/virtual_image_format.go b/test/e2e/blockdevice/virtual_image_format.go new file mode 100644 index 0000000000..069f03f660 --- /dev/null +++ b/test/e2e/blockdevice/virtual_image_format.go @@ -0,0 +1,461 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package blockdevice + +import ( + "context" + "encoding/binary" + "fmt" + "image" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/gorilla/websocket" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/client-go/rest" + "k8s.io/utils/ptr" + + vdbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vd" + vibuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vi" + vmbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vm" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + vmobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/vm" + "github.com/deckhouse/virtualization/test/e2e/internal/precheck" +) + +// VirtualImageFormat verifies how image formats are handled when the source is an HTTP +// data source: +// - an ISO VirtualImage boots a VirtualMachine directly (as a CD-ROM); +// - a qcow2 VirtualImage backs a VirtualDisk, and a VirtualMachine boots from that disk. +// +// The qcow2 spec provisions its main VirtualDisk on the WFFC StorageClass, so the precheck +// label is declared on the Describe (the spec-label validator only reads container-hierarchy +// labels, not leaf It labels). +var _ = Describe("VirtualImageFormat", Label(precheck.PrecheckWFFCStorageClass), func() { + var ( + f *framework.Framework + ctx context.Context + ) + + BeforeEach(func() { + ctx = context.Background() + f = framework.NewFramework("") + f.Before() + DeferCleanup(f.After) + setupProject(ctx, f, "vi-format") + }) + + It("boots a VirtualMachine from an iso VirtualImage and shows the installer screen", func() { + vi := vibuilder.New( + vibuilder.WithName("vi-iso"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), + vibuilder.WithDataSourceHTTP(object.ImageURLUbuntuISO, nil, nil), + ) + + createVirtualImageAndWait(ctx, f, vi) + + runVirtualMachineFromImageUntilRunning(ctx, f, vi) + }) + + It("provisions a VirtualDisk from a qcow2 VirtualImage and runs a VirtualMachine with a ready agent", func() { + vi := vibuilder.New( + vibuilder.WithName("vi-qcow2"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), + vibuilder.WithDataSourceHTTP(object.ImageURLAlpineBIOS, nil, nil), + ) + + createVirtualImageAndWait(ctx, f, vi) + + // The disk under test is the scenario's main resource, so it lives on the WFFC + // storage class. + vd := object.NewVDFromVI("vd-from-vi-qcow2", f.Namespace().Name, vi, + vdbuilder.WithStorageClass(wffcStorageClass()), + vdbuilder.WithSize(ptr.To(resource.MustParse("350Mi")))) + + createVirtualDiskAndRunVM(ctx, f, vd) + }) +}) + +// runVirtualMachineFromImageUntilRunning boots a VirtualMachine from vi with a blank +// target disk and verifies the installer screen over VNC. It does not wait for the +// guest agent, which is not available when booting from CD-ROM/ISO media. +func runVirtualMachineFromImageUntilRunning(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage) { + GinkgoHelper() + + blankVD := object.NewBlankVD("vd-blank-for-iso", f.Namespace().Name, wffcStorageClass(), ptr.To(resource.MustParse("4Gi"))) + vm := object.NewMinimalVM("vm-from-vi-", f.Namespace().Name, + vmbuilder.WithBootloader(v1alpha2.EFI), + vmbuilder.WithCPU(2, ptr.To("100%")), + vmbuilder.WithMemory(resource.MustParse("2Gi")), + vmbuilder.WithProvisioning(nil), + vmbuilder.WithRunPolicy(v1alpha2.AlwaysOnPolicy), + vmbuilder.WithBlockDeviceRefs(v1alpha2.BlockDeviceSpecRef{ + Kind: v1alpha2.ImageDevice, + Name: vi.Name, + BootOrder: ptr.To(uint(1)), + }, v1alpha2.BlockDeviceSpecRef{ + Kind: v1alpha2.DiskDevice, + Name: blankVD.Name, + BootOrder: ptr.To(uint(2)), + }), + ) + + By("Creating blank VirtualDisk and VirtualMachine from the VirtualImage", func() { + err := f.CreateWithDeferredDeletion(ctx, blankVD, vm) + Expect(err).NotTo(HaveOccurred()) + }) + + obs := vmobs.StartObserver(ctx, f, vm) + obs.Never(vmobs.BeFailed()) + obs.Never(vmobs.HaveNoBootableDevice()) + + By("Waiting for the VirtualMachine to be Running", func() { + err := obs.WaitFor(vmobs.BeRunning(), framework.LongTimeout) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Checking that the OS installer boot screen is visible over VNC", func() { + Eventually(func(g Gomega) { + frame, err := captureVNCFrame(ctx, vm) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(hasUbuntuInstallerBootScreen(frame)).To(BeTrue(), "expected Ubuntu installer language selection screen") + }).WithTimeout(framework.MiddleTimeout).WithPolling(5 * time.Second).Should(Succeed()) + }) +} + +type vncFrame struct { + width int + height int + pixels []byte +} + +func captureVNCFrame(ctx context.Context, vm *v1alpha2.VirtualMachine) (*vncFrame, error) { + GinkgoHelper() + + restConfig, err := framework.GetConfig().ClusterTransport.RestConfig() + if err != nil { + return nil, fmt.Errorf("get rest config: %w", err) + } + + vncURL, err := vncWebSocketURL(restConfig, vm) + if err != nil { + return nil, err + } + + tlsConfig, err := rest.TLSConfigFor(restConfig) + if err != nil { + return nil, fmt.Errorf("build tls config: %w", err) + } + + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: tlsConfig, + } + + dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + conn, resp, err := dialer.DialContext(dialCtx, vncURL.String(), vncHeaders(restConfig)) + if resp != nil && resp.Body != nil { + defer func() { + _ = resp.Body.Close() + }() + } + if err != nil { + return nil, fmt.Errorf("connect to VNC websocket: %w", err) + } + defer func() { + _ = conn.Close() + }() + + deadline := time.Now().Add(15 * time.Second) + _ = conn.SetReadDeadline(deadline) + _ = conn.SetWriteDeadline(deadline) + + rfb := &rfbStream{conn: conn} + return rfb.captureFrame() +} + +func vncWebSocketURL(restConfig *rest.Config, vm *v1alpha2.VirtualMachine) (*url.URL, error) { + u, err := url.Parse(restConfig.Host) + if err != nil { + return nil, fmt.Errorf("parse API server URL: %w", err) + } + + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + default: + return nil, fmt.Errorf("unsupported API server scheme %q", u.Scheme) + } + + basePath := strings.TrimRight(u.Path, "/") + u.Path = fmt.Sprintf( + "%s/apis/subresources.virtualization.deckhouse.io/v1alpha2/namespaces/%s/virtualmachines/%s/vnc", + basePath, + vm.Namespace, + vm.Name, + ) + u.RawQuery = "" + return u, nil +} + +func vncHeaders(restConfig *rest.Config) http.Header { + headers := http.Header{} + if restConfig.BearerToken != "" { + headers.Set("Authorization", "Bearer "+restConfig.BearerToken) + return headers + } + if restConfig.BearerTokenFile != "" { + token, err := os.ReadFile(restConfig.BearerTokenFile) + if err == nil { + headers.Set("Authorization", "Bearer "+strings.TrimSpace(string(token))) + return headers + } + } + if restConfig.Username != "" || restConfig.Password != "" { + req := &http.Request{Header: headers} + req.SetBasicAuth(restConfig.Username, restConfig.Password) + } + return headers +} + +type rfbStream struct { + conn *websocket.Conn + buf []byte +} + +func (s *rfbStream) captureFrame() (*vncFrame, error) { + protocol, err := s.readFull(12) + if err != nil { + return nil, fmt.Errorf("read RFB protocol version: %w", err) + } + if !strings.HasPrefix(string(protocol), "RFB ") { + return nil, fmt.Errorf("unexpected RFB protocol version %q", string(protocol)) + } + if err := s.write(protocol); err != nil { + return nil, fmt.Errorf("write RFB protocol version: %w", err) + } + + securityTypeCountRaw, err := s.readFull(1) + if err != nil { + return nil, fmt.Errorf("read RFB security type count: %w", err) + } + securityTypes, err := s.readFull(int(securityTypeCountRaw[0])) + if err != nil { + return nil, fmt.Errorf("read RFB security types: %w", err) + } + if !containsByte(securityTypes, 1) { + return nil, fmt.Errorf("RFB server does not offer no-auth security type: %v", securityTypes) + } + if err := s.write([]byte{1}); err != nil { + return nil, fmt.Errorf("select RFB no-auth security type: %w", err) + } + + securityResult, err := s.readFull(4) + if err != nil { + return nil, fmt.Errorf("read RFB security result: %w", err) + } + if binary.BigEndian.Uint32(securityResult) != 0 { + return nil, fmt.Errorf("RFB security handshake failed with code %d", binary.BigEndian.Uint32(securityResult)) + } + + if err := s.write([]byte{1}); err != nil { + return nil, fmt.Errorf("write RFB shared flag: %w", err) + } + + serverInit, err := s.readFull(24) + if err != nil { + return nil, fmt.Errorf("read RFB server init: %w", err) + } + width := int(binary.BigEndian.Uint16(serverInit[0:2])) + height := int(binary.BigEndian.Uint16(serverInit[2:4])) + nameLength := int(binary.BigEndian.Uint32(serverInit[20:24])) + if _, err := s.readFull(nameLength); err != nil { + return nil, fmt.Errorf("read RFB desktop name: %w", err) + } + + if err := s.write(setPixelFormatMessage()); err != nil { + return nil, fmt.Errorf("set RFB pixel format: %w", err) + } + if err := s.write(setRawEncodingMessage()); err != nil { + return nil, fmt.Errorf("set RFB raw encoding: %w", err) + } + if err := s.write(framebufferUpdateRequest(width, height)); err != nil { + return nil, fmt.Errorf("request RFB framebuffer update: %w", err) + } + + return s.readFramebufferUpdate(width, height) +} + +func (s *rfbStream) readFull(size int) ([]byte, error) { + for len(s.buf) < size { + messageType, message, err := s.conn.ReadMessage() + if err != nil { + return nil, err + } + if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage { + continue + } + s.buf = append(s.buf, message...) + } + + result := s.buf[:size] + s.buf = s.buf[size:] + return result, nil +} + +func (s *rfbStream) write(data []byte) error { + return s.conn.WriteMessage(websocket.BinaryMessage, data) +} + +func (s *rfbStream) readFramebufferUpdate(width, height int) (*vncFrame, error) { + frame := &vncFrame{ + width: width, + height: height, + pixels: make([]byte, width*height*4), + } + + for { + messageTypeRaw, err := s.readFull(1) + if err != nil { + return nil, fmt.Errorf("read RFB server message type: %w", err) + } + if messageTypeRaw[0] != 0 { + continue + } + + header, err := s.readFull(3) + if err != nil { + return nil, fmt.Errorf("read RFB framebuffer update header: %w", err) + } + rectangles := int(binary.BigEndian.Uint16(header[1:3])) + for i := 0; i < rectangles; i++ { + rectHeader, err := s.readFull(12) + if err != nil { + return nil, fmt.Errorf("read RFB rectangle header: %w", err) + } + x := int(binary.BigEndian.Uint16(rectHeader[0:2])) + y := int(binary.BigEndian.Uint16(rectHeader[2:4])) + w := int(binary.BigEndian.Uint16(rectHeader[4:6])) + h := int(binary.BigEndian.Uint16(rectHeader[6:8])) + encoding := int32(binary.BigEndian.Uint32(rectHeader[8:12])) + if encoding != 0 { + return nil, fmt.Errorf("unsupported RFB rectangle encoding %d", encoding) + } + + raw, err := s.readFull(w * h * 4) + if err != nil { + return nil, fmt.Errorf("read RFB raw rectangle: %w", err) + } + copyRawRectangle(frame, image.Rect(x, y, x+w, y+h), raw) + } + return frame, nil + } +} + +func setPixelFormatMessage() []byte { + msg := make([]byte, 20) + msg[4] = 32 + msg[5] = 24 + msg[6] = 0 + msg[7] = 1 + binary.BigEndian.PutUint16(msg[8:10], 255) + binary.BigEndian.PutUint16(msg[10:12], 255) + binary.BigEndian.PutUint16(msg[12:14], 255) + msg[14] = 16 + msg[15] = 8 + msg[16] = 0 + return msg +} + +func setRawEncodingMessage() []byte { + msg := make([]byte, 8) + msg[0] = 2 + binary.BigEndian.PutUint16(msg[2:4], 1) + return msg +} + +func framebufferUpdateRequest(width, height int) []byte { + msg := make([]byte, 10) + msg[0] = 3 + binary.BigEndian.PutUint16(msg[6:8], uint16(width)) + binary.BigEndian.PutUint16(msg[8:10], uint16(height)) + return msg +} + +func copyRawRectangle(frame *vncFrame, rect image.Rectangle, raw []byte) { + for y := rect.Min.Y; y < rect.Max.Y; y++ { + dstOffset := (y*frame.width + rect.Min.X) * 4 + srcOffset := ((y - rect.Min.Y) * rect.Dx()) * 4 + copy(frame.pixels[dstOffset:dstOffset+rect.Dx()*4], raw[srcOffset:srcOffset+rect.Dx()*4]) + } +} + +func hasUbuntuInstallerBootScreen(frame *vncFrame) bool { + if frame == nil || frame.width == 0 || frame.height == 0 { + return false + } + + orangePixels := 0 + for y := 0; y < min(frame.height, 40); y++ { + for x := 0; x < frame.width; x++ { + r, g, b := frame.rgbAt(x, y) + if r > 180 && g > 40 && g < 140 && b < 90 { + orangePixels++ + } + } + } + + greenPixels := 0 + for y := 0; y < frame.height; y++ { + for x := 0; x < frame.width; x++ { + r, g, b := frame.rgbAt(x, y) + if r < 80 && g > 100 && b < 80 { + greenPixels++ + } + } + } + + return orangePixels > frame.width*10 && greenPixels > frame.width*2 +} + +func (f *vncFrame) rgbAt(x, y int) (r, g, b byte) { + offset := (y*f.width + x) * 4 + return f.pixels[offset+2], f.pixels[offset+1], f.pixels[offset] +} + +func containsByte(values []byte, want byte) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/test/e2e/default_config.yaml b/test/e2e/default_config.yaml index ba96dcd37d..0ba0831d06 100644 --- a/test/e2e/default_config.yaml +++ b/test/e2e/default_config.yaml @@ -32,7 +32,6 @@ logFilter: - "failed to sync powerstate" # Msg. - "does not have a pvc reference" # "err": "kvvm head-345e7b6a-testcases-image-hotplug/head-345e7b6a-vm-image-hotplug spec volume vi-head-345e7b6a-vi-alpine-http does not have a pvc reference" - "virtualmachineipaddressleases.virtualization.deckhouse.io " - - "Failed to watch" # error if virtualization-controller restarts during tests. "msg": "Failed to watch", "err": "Get \"http://127.0.0.1:23915/apis/virtualization.deckhouse.io/v1alpha2/virtualmachinerestores?allowWatchBookmarks=true\u0026resourceVersion=709816257\u0026timeoutSeconds=310\u0026watch=true\": context canceled" - "leader election lost" - 'virtualMachineSnapshotSecret \"\" not found' # "msg": "virtualMachineSnapshotSecret \"\" not found" - 'failed to sync migrating volumes: admission webhook \"virtualmachine-validator.kubevirt.io\" denied the request: spec.template.spec.domain.devices.disks' @@ -47,7 +46,6 @@ regexpLogFilter: - "failed to detach: .* not found" # "err" "failed to detach: virtualmachine.kubevirt.io \"head-497d17b-vm-automatic-with-hotplug\" not found", - "error patching .* not found" # "err" "error patching *** virtualimages.virtualization.deckhouse.io \"head-497d17b-vi-pvc-oref-vi-oref-vd\" not found", - "failed to get vmSnapshot: VirtualMachineSnapshot\\.virtualization\\.deckhouse.io .* not found" # "msg": "failed to get vmSnapshot: VirtualMachineSnapshot.virtualization.deckhouse.io \"main-to-pr14969-ynv-0-ef17ba-20250908-142437\" not found" - - "failed to sync virtual disk data source objectref: start immediate: internalvirtualizationdatavolumes.cdi.internal.virtualization.deckhouse.io .* is forbidden: unable to create new content in namespace .* because it is being terminated" # "err": "failed to sync virtual disk data source objectref: start immediate: internalvirtualizationdatavolumes.cdi.internal.virtualization.deckhouse.io \"vd-head-b3d8865-vd-root-migration-bios-d77ea313-f469-463d-a71b-00c89ca542ab\" is forbidden: unable to create new content in namespace head-b3d8865-end-to-end-vm-migration because it is being terminated" - "Failed to update lock optimistically:.*leases.*leader-election-helper.*" # error during virtualization-controller lifecycle: attempt to reacquire leader election. "msg": "Failed to update lock optimistically: Put \"http://127.0.0.1:23915/apis/coordination.k8s.io/v1/namespaces/d8-virtualization/leases/d8-virt-operator-leader-election-helper?timeout=5s\": context deadline exceeded (Client.Timeout exceeded while awaiting headers), falling back to slow path" - "Failed to update lock: .* leases.*leader-election-helper.*" # "msg": "ock: Operation cannot be fulfilled on leases.coordination.k8s.io \"d8-virt-operator-leader-election-helper\": the object has been modified; please apply your changes to the latest version and try again", - "error retrieving resource lock .*leader-election-helper" # "msg": "error retrieving resource lock d8-virtualization/d8-virt-operator-leader-election-helper: context deadline exceeded", diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index be3911b5bd..3b2ef28166 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -30,6 +30,7 @@ import ( "github.com/deckhouse/virtualization/test/e2e/internal/framework" "github.com/deckhouse/virtualization/test/e2e/internal/precheck" "github.com/deckhouse/virtualization/test/e2e/legacy" + _ "github.com/deckhouse/virtualization/test/e2e/populator" _ "github.com/deckhouse/virtualization/test/e2e/snapshot" _ "github.com/deckhouse/virtualization/test/e2e/vm" _ "github.com/deckhouse/virtualization/test/e2e/vmop" diff --git a/test/e2e/go.mod b/test/e2e/go.mod index e19aacb7e1..9b2bdd0318 100644 --- a/test/e2e/go.mod +++ b/test/e2e/go.mod @@ -7,6 +7,7 @@ tool github.com/onsi/ginkgo/v2/ginkgo require ( github.com/deckhouse/virtualization-controller v0.0.0-00010101000000-000000000000 github.com/deckhouse/virtualization/api v0.0.0-20240923080356-bb5809dba578 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/onsi/ginkgo/v2 v2.23.3 github.com/onsi/gomega v1.37.0 github.com/stretchr/testify v1.11.1 @@ -43,7 +44,6 @@ require ( github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/test/e2e/internal/config/config.go b/test/e2e/internal/config/config.go index 07c168d637..f13e48b6f4 100644 --- a/test/e2e/internal/config/config.go +++ b/test/e2e/internal/config/config.go @@ -118,9 +118,20 @@ type TestData struct { } type StorageClass struct { - DefaultStorageClass *storagev1.StorageClass + DefaultStorageClass *storagev1.StorageClass + // ImmediateStorageClass is resolved from IMMEDIATE_STORAGE_CLASS or derived from the default + // StorageClass. It uses the Immediate volume binding mode and shares the CSI driver with + // WFFCStorageClass, so tests can use it both as the "other" same-CSI StorageClass and to + // provision dependent objects that must become Ready without a consumer. ImmediateStorageClass *storagev1.StorageClass TemplateStorageClass *storagev1.StorageClass + // WFFCStorageClass is resolved from WFFC_STORAGE_CLASS or derived from the default + // StorageClass. It uses the WaitForFirstConsumer volume binding mode and backs the + // scenario's main VirtualDisks and VirtualImages. + WFFCStorageClass *storagev1.StorageClass + // DifferentCSIDriverStorageClass is a StorageClass backed by a different CSI driver + // than WFFCStorageClass, used to verify cross-CSI provisioning is rejected. + DifferentCSIDriverStorageClass *storagev1.StorageClass } type ClusterTransport struct { diff --git a/test/e2e/internal/config/storageclass.go b/test/e2e/internal/config/storageclass.go index 91790fbc1e..6e4cf9e44e 100644 --- a/test/e2e/internal/config/storageclass.go +++ b/test/e2e/internal/config/storageclass.go @@ -31,6 +31,18 @@ const ( // StorageClassNameEnv overrides TemplateStorageClass for tests (see README). StorageClassNameEnv = "STORAGE_CLASS_NAME" + + // WFFCStorageClassEnv overrides the WaitForFirstConsumer StorageClass used by block-device + // tests. When unset, the class is derived from the default StorageClass: the default itself + // when it uses WaitForFirstConsumer, or another StorageClass on the same CSI driver when + // the default uses Immediate binding. + WFFCStorageClassEnv = "WFFC_STORAGE_CLASS" + + // ImmediateStorageClassEnv overrides the Immediate StorageClass used by block-device tests. + // When unset, the class is derived from the default StorageClass: the default itself when + // it uses Immediate binding, or another StorageClass on the same CSI driver when the + // default uses WaitForFirstConsumer. + ImmediateStorageClassEnv = "IMMEDIATE_STORAGE_CLASS" ) // FindDefaultStorageClass returns the default StorageClass from the list. @@ -62,33 +74,150 @@ func FindDefaultStorageClass(scList *storagev1.StorageClassList) *storagev1.Stor return &defaultClasses[0] } -// FindImmediateStorageClass finds an immediate StorageClass with the same provisioner as defaultSC. -// It checks if defaultSC has Immediate binding mode first, then searches for an immediate SC with same provisioner. -// Returns the immediate StorageClass if found, or nil if not found. -func FindImmediateStorageClass(defaultSC *storagev1.StorageClass, scList *storagev1.StorageClassList) *storagev1.StorageClass { - if defaultSC == nil { - return nil +// IsWFFCBinding reports whether sc uses the WaitForFirstConsumer volume binding mode. +func IsWFFCBinding(sc *storagev1.StorageClass) bool { + return sc.VolumeBindingMode != nil && *sc.VolumeBindingMode == storagev1.VolumeBindingWaitForFirstConsumer +} + +// IsImmediateBinding reports whether sc uses the Immediate volume binding mode. A nil +// VolumeBindingMode defaults to Immediate per the Kubernetes API. +func IsImmediateBinding(sc *storagev1.StorageClass) bool { + return sc.VolumeBindingMode == nil || *sc.VolumeBindingMode == storagev1.VolumeBindingImmediate +} + +// VolumeBindingMode returns sc's volume binding mode for diagnostics, rendering a nil +// mode as its Immediate default. +func VolumeBindingMode(sc *storagev1.StorageClass) storagev1.VolumeBindingMode { + if sc.VolumeBindingMode == nil { + return storagev1.VolumeBindingImmediate } + return *sc.VolumeBindingMode +} - // If default StorageClass already has Immediate binding mode, use it - if defaultSC.VolumeBindingMode != nil && - *defaultSC.VolumeBindingMode == storagev1.VolumeBindingImmediate { - return defaultSC +// FindStorageClassWithDifferentProvisioner returns a StorageClass whose provisioner (CSI +// driver) differs from the given provisioner. When several match, the one with the +// smallest name is returned for determinism. Returns nil if none match (the cluster has +// only a single CSI driver). +func FindStorageClassWithDifferentProvisioner(scList *storagev1.StorageClassList, provisioner string) *storagev1.StorageClass { + var match *storagev1.StorageClass + for i := range scList.Items { + sc := &scList.Items[i] + if sc.Provisioner == "" || sc.Provisioner == provisioner { + continue + } + if match == nil || sc.Name < match.Name { + match = sc + } } - // Find immediate StorageClass with same provisioner + return match +} + +// FindStorageClassWithProvisionerAndBinding returns a StorageClass backed by provisioner +// with the requested volume binding mode. When several match, the most recently created +// one is returned (ties are broken by name, ascending). +func FindStorageClassWithProvisionerAndBinding( + scList *storagev1.StorageClassList, + provisioner string, + wffc bool, +) *storagev1.StorageClass { + var matched []storagev1.StorageClass for i := range scList.Items { sc := &scList.Items[i] - if sc.VolumeBindingMode == nil { + if sc.Provisioner != provisioner { continue } - if *sc.VolumeBindingMode == storagev1.VolumeBindingImmediate && - sc.Provisioner == defaultSC.Provisioner { - return sc + if wffc && IsWFFCBinding(sc) { + matched = append(matched, *sc) + } + if !wffc && IsImmediateBinding(sc) { + matched = append(matched, *sc) } } - return nil + if len(matched) == 0 { + return nil + } + + sort.Slice(matched, func(i, j int) bool { + if matched[i].CreationTimestamp.UnixNano() == matched[j].CreationTimestamp.UnixNano() { + return matched[i].Name < matched[j].Name + } + return matched[i].CreationTimestamp.UnixNano() > matched[j].CreationTimestamp.UnixNano() + }) + + return &matched[0] +} + +// ResolveWFFCStorageClass returns the WaitForFirstConsumer StorageClass for block-device +// tests. When WFFC_STORAGE_CLASS is set, that StorageClass is returned. Otherwise the class +// is derived from the default StorageClass: the default itself when it uses +// WaitForFirstConsumer, or another StorageClass on the same CSI driver when the default +// uses Immediate binding. Returns nil when no default StorageClass is configured and the +// env var is unset, or when auto-detection finds no matching StorageClass. +func ResolveWFFCStorageClass(scList *storagev1.StorageClassList) (*storagev1.StorageClass, error) { + if sc, err := resolveStorageClassFromEnv(scList, WFFCStorageClassEnv); err != nil || sc != nil { + return sc, err + } + + defaultSC := FindDefaultStorageClass(scList) + if defaultSC == nil { + return nil, nil + } + + if IsWFFCBinding(defaultSC) { + return defaultSC, nil + } + + if IsImmediateBinding(defaultSC) { + return FindStorageClassWithProvisionerAndBinding(scList, defaultSC.Provisioner, true), nil + } + + return nil, nil +} + +// ResolveImmediateStorageClass returns the Immediate StorageClass for block-device tests. +// When IMMEDIATE_STORAGE_CLASS is set, that StorageClass is returned. Otherwise the class +// is derived from the default StorageClass: the default itself when it uses Immediate +// binding, or another StorageClass on the same CSI driver when the default uses +// WaitForFirstConsumer. Returns nil when no default StorageClass is configured and the +// env var is unset, or when auto-detection finds no matching StorageClass. +func ResolveImmediateStorageClass(scList *storagev1.StorageClassList) (*storagev1.StorageClass, error) { + if sc, err := resolveStorageClassFromEnv(scList, ImmediateStorageClassEnv); err != nil || sc != nil { + return sc, err + } + + defaultSC := FindDefaultStorageClass(scList) + if defaultSC == nil { + return nil, nil + } + + if IsImmediateBinding(defaultSC) { + return defaultSC, nil + } + + if IsWFFCBinding(defaultSC) { + return FindStorageClassWithProvisionerAndBinding(scList, defaultSC.Provisioner, false), nil + } + + return nil, nil +} + +// ResolveTemplateStorageClass returns the StorageClass used by tests that allow either an +// explicit STORAGE_CLASS_NAME override or the cluster default StorageClass. +func ResolveTemplateStorageClass(scList *storagev1.StorageClassList) (*storagev1.StorageClass, error) { + scName, ok := os.LookupEnv(StorageClassNameEnv) + if ok { + if scName == "" { + return nil, fmt.Errorf("%s env is set but empty", StorageClassNameEnv) + } + if sc := findStorageClassInList(scList, scName); sc != nil { + return sc, nil + } + return nil, fmt.Errorf("StorageClass %q from %s env not found", scName, StorageClassNameEnv) + } + + return FindDefaultStorageClass(scList), nil } // SetStorageClasses discovers cluster StorageClasses and populates Config.StorageClass fields. @@ -100,46 +229,54 @@ func (c *Config) SetStorageClasses(ctx context.Context, k8sClient client.Client) } c.StorageClass.DefaultStorageClass = FindDefaultStorageClass(&scList) - if c.StorageClass.DefaultStorageClass == nil { - return fmt.Errorf("default StorageClass not found in the cluster") + + wffcSC, err := ResolveWFFCStorageClass(&scList) + if err != nil { + return err } + c.StorageClass.WFFCStorageClass = wffcSC - c.StorageClass.ImmediateStorageClass = FindImmediateStorageClass(c.StorageClass.DefaultStorageClass, &scList) + immediateSC, err := ResolveImmediateStorageClass(&scList) + if err != nil { + return err + } + c.StorageClass.ImmediateStorageClass = immediateSC + + if c.StorageClass.WFFCStorageClass != nil { + c.StorageClass.DifferentCSIDriverStorageClass = FindStorageClassWithDifferentProvisioner( + &scList, c.StorageClass.WFFCStorageClass.Provisioner, + ) + } - templateSC, err := findStorageClassFromEnv(ctx, k8sClient, StorageClassNameEnv, &scList) + templateSC, err := ResolveTemplateStorageClass(&scList) if err != nil { return err } if templateSC != nil { c.StorageClass.TemplateStorageClass = templateSC - } else { - c.StorageClass.TemplateStorageClass = c.StorageClass.DefaultStorageClass } return nil } -func findStorageClassFromEnv( - ctx context.Context, - k8sClient client.Client, - envName string, - scList *storagev1.StorageClassList, -) (*storagev1.StorageClass, error) { +func resolveStorageClassFromEnv(scList *storagev1.StorageClassList, envName string) (*storagev1.StorageClass, error) { scName, ok := os.LookupEnv(envName) - if !ok { + if !ok || scName == "" { return nil, nil } - for i := range scList.Items { - if scList.Items[i].Name == scName { - return &scList.Items[i], nil - } + if sc := findStorageClassInList(scList, scName); sc != nil { + return sc, nil } - sc := &storagev1.StorageClass{} - if err := k8sClient.Get(ctx, client.ObjectKey{Name: scName}, sc); err != nil { - return nil, fmt.Errorf("failed to get StorageClass %q from %s env: %w", scName, envName, err) - } + return nil, fmt.Errorf("StorageClass %q from %s env not found", scName, envName) +} - return sc, nil +func findStorageClassInList(scList *storagev1.StorageClassList, name string) *storagev1.StorageClass { + for i := range scList.Items { + if scList.Items[i].Name == name { + return &scList.Items[i] + } + } + return nil } diff --git a/test/e2e/internal/config/storageclass_test.go b/test/e2e/internal/config/storageclass_test.go new file mode 100644 index 0000000000..667cdf0a0f --- /dev/null +++ b/test/e2e/internal/config/storageclass_test.go @@ -0,0 +1,227 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "os" + "testing" + + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestResolveTemplateStorageClass(t *testing.T) { + scList := &storagev1.StorageClassList{Items: []storagev1.StorageClass{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "linstor-thin-r1", + Annotations: map[string]string{ + "storageclass.kubernetes.io/is-default-class": "true", + }, + }, + Provisioner: "replicated.csi.storage.deckhouse.io", + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "sds-local-thin-wffc"}, + Provisioner: "local.csi.storage.deckhouse.io", + }, + }} + + t.Run("uses default when env is unset", func(t *testing.T) { + unsetStorageClassNameEnv(t) + + got, err := ResolveTemplateStorageClass(scList) + if err != nil { + t.Fatalf("ResolveTemplateStorageClass() error = %v", err) + } + if got == nil || got.Name != "linstor-thin-r1" { + t.Fatalf("ResolveTemplateStorageClass() = %#v, want linstor-thin-r1", got) + } + }) + + t.Run("uses env override", func(t *testing.T) { + unsetStorageClassNameEnv(t) + t.Setenv(StorageClassNameEnv, "sds-local-thin-wffc") + + got, err := ResolveTemplateStorageClass(scList) + if err != nil { + t.Fatalf("ResolveTemplateStorageClass() with env error = %v", err) + } + if got == nil || got.Name != "sds-local-thin-wffc" { + t.Fatalf("ResolveTemplateStorageClass() with env = %#v, want sds-local-thin-wffc", got) + } + }) + + t.Run("errors on missing env override", func(t *testing.T) { + unsetStorageClassNameEnv(t) + t.Setenv(StorageClassNameEnv, "missing-sc") + + if _, err := ResolveTemplateStorageClass(scList); err == nil { + t.Fatal("ResolveTemplateStorageClass() with missing env SC expected error") + } + }) + + t.Run("errors on empty env override", func(t *testing.T) { + unsetStorageClassNameEnv(t) + t.Setenv(StorageClassNameEnv, "") + + if _, err := ResolveTemplateStorageClass(scList); err == nil { + t.Fatal("ResolveTemplateStorageClass() with empty env SC expected error") + } + }) + + t.Run("returns nil without default and env", func(t *testing.T) { + unsetStorageClassNameEnv(t) + scListWithoutDefault := &storagev1.StorageClassList{Items: []storagev1.StorageClass{{ + ObjectMeta: metav1.ObjectMeta{Name: "sds-local-thin-wffc"}, + Provisioner: "local.csi.storage.deckhouse.io", + }}} + + got, err := ResolveTemplateStorageClass(scListWithoutDefault) + if err != nil { + t.Fatalf("ResolveTemplateStorageClass() without default error = %v", err) + } + if got != nil { + t.Fatalf("ResolveTemplateStorageClass() without default = %#v, want nil", got) + } + }) +} + +func TestResolveWFFCStorageClass(t *testing.T) { + wffc := storagev1.VolumeBindingWaitForFirstConsumer + immediate := storagev1.VolumeBindingImmediate + provisioner := "replicated.csi.storage.deckhouse.io" + + scList := &storagev1.StorageClassList{Items: []storagev1.StorageClass{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rv-thin-r1", + Annotations: map[string]string{ + "storageclass.kubernetes.io/is-default-class": "true", + }, + }, + Provisioner: provisioner, + VolumeBindingMode: &immediate, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "rv-thin-r1-wffc"}, + Provisioner: provisioner, + VolumeBindingMode: &wffc, + }, + }} + + got, err := ResolveWFFCStorageClass(scList) + if err != nil { + t.Fatalf("ResolveWFFCStorageClass() error = %v", err) + } + if got == nil || got.Name != "rv-thin-r1-wffc" { + t.Fatalf("ResolveWFFCStorageClass() = %#v, want rv-thin-r1-wffc", got) + } + + scList.Items[0].VolumeBindingMode = &wffc + scList.Items[0].Annotations = map[string]string{ + "storageclass.kubernetes.io/is-default-class": "true", + } + scList.Items[1].VolumeBindingMode = &immediate + + got, err = ResolveWFFCStorageClass(scList) + if err != nil { + t.Fatalf("ResolveWFFCStorageClass() error = %v", err) + } + if got == nil || got.Name != "rv-thin-r1" { + t.Fatalf("ResolveWFFCStorageClass() = %#v, want rv-thin-r1", got) + } + + t.Setenv(WFFCStorageClassEnv, "rv-thin-r1-wffc") + got, err = ResolveWFFCStorageClass(scList) + if err != nil { + t.Fatalf("ResolveWFFCStorageClass() with env error = %v", err) + } + if got == nil || got.Name != "rv-thin-r1-wffc" { + t.Fatalf("ResolveWFFCStorageClass() with env = %#v, want rv-thin-r1-wffc", got) + } + + t.Setenv(WFFCStorageClassEnv, "missing-sc") + if _, err := ResolveWFFCStorageClass(scList); err == nil { + t.Fatal("ResolveWFFCStorageClass() with missing env SC expected error") + } + + t.Setenv(WFFCStorageClassEnv, "") + scList.Items[0].Annotations = nil + if got, err := ResolveWFFCStorageClass(scList); err != nil || got != nil { + t.Fatalf("ResolveWFFCStorageClass() without default = (%#v, %v), want (nil, nil)", got, err) + } +} + +func unsetStorageClassNameEnv(t *testing.T) { + t.Helper() + + oldValue, wasSet := os.LookupEnv(StorageClassNameEnv) + if wasSet { + t.Setenv(StorageClassNameEnv, oldValue) + } else { + t.Setenv(StorageClassNameEnv, "") + } + if err := os.Unsetenv(StorageClassNameEnv); err != nil { + t.Fatalf("failed to unset %s: %v", StorageClassNameEnv, err) + } +} + +func TestResolveImmediateStorageClass(t *testing.T) { + wffc := storagev1.VolumeBindingWaitForFirstConsumer + immediate := storagev1.VolumeBindingImmediate + provisioner := "replicated.csi.storage.deckhouse.io" + + scList := &storagev1.StorageClassList{Items: []storagev1.StorageClass{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "rv-thin-r1-wffc", + Annotations: map[string]string{ + "storageclass.kubernetes.io/is-default-class": "true", + }, + }, + Provisioner: provisioner, + VolumeBindingMode: &wffc, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "rv-thin-r1"}, + Provisioner: provisioner, + VolumeBindingMode: &immediate, + }, + }} + + got, err := ResolveImmediateStorageClass(scList) + if err != nil { + t.Fatalf("ResolveImmediateStorageClass() error = %v", err) + } + if got == nil || got.Name != "rv-thin-r1" { + t.Fatalf("ResolveImmediateStorageClass() = %#v, want rv-thin-r1", got) + } + + scList.Items[0].VolumeBindingMode = &immediate + scList.Items[0].Name = "rv-thin-r1" + scList.Items[1].VolumeBindingMode = &wffc + scList.Items[1].Name = "rv-thin-r1-wffc" + + got, err = ResolveImmediateStorageClass(scList) + if err != nil { + t.Fatalf("ResolveImmediateStorageClass() error = %v", err) + } + if got == nil || got.Name != "rv-thin-r1" { + t.Fatalf("ResolveImmediateStorageClass() = %#v, want rv-thin-r1", got) + } +} diff --git a/test/e2e/internal/framework/framework.go b/test/e2e/internal/framework/framework.go index e7092f8f7d..3b18186283 100644 --- a/test/e2e/internal/framework/framework.go +++ b/test/e2e/internal/framework/framework.go @@ -44,6 +44,7 @@ type Framework struct { Clients skipNsCreation bool + skipNsDeletion bool namespacePrefix string namespace *corev1.Namespace @@ -74,7 +75,7 @@ func (f *Framework) After() { if GetConfig().IsCleanupNeeded() { defer func() { - if f.namespace != nil { + if f.namespace != nil && !f.skipNsDeletion { By("Cleanup: delete namespace") err := f.Delete(context.Background(), f.namespace) Expect(err).NotTo(HaveOccurred(), "Failed to delete namespace %q", f.namespace.Name) @@ -122,6 +123,21 @@ func (f *Framework) Namespace() *corev1.Namespace { return f.namespace } +// SetProjectNamespace makes the framework operate inside an externally managed namespace, +// such as the one created by a Deckhouse Project. After does not delete this namespace: +// the owning resource (e.g. the Project) is expected to be cleaned up separately (for +// instance via CreateWithDeferredDeletion). +func (f *Framework) SetProjectNamespace(name string) { + f.namespace = &corev1.Namespace{ + TypeMeta: metav1.TypeMeta{ + Kind: "Namespace", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: name}, + } + f.skipNsDeletion = true +} + func (f *Framework) DeferDelete(objs ...client.Object) { f.objectsToDelete = append(f.objectsToDelete, objs...) } diff --git a/test/e2e/internal/object/project.go b/test/e2e/internal/object/project.go index fb98e22783..2abb3e7b06 100644 --- a/test/e2e/internal/object/project.go +++ b/test/e2e/internal/object/project.go @@ -24,7 +24,22 @@ import ( dv1alpha2 "github.com/deckhouse/virtualization/test/e2e/internal/api/deckhouse/v1alpha2" ) +// NewIsolatedProject builds a Project with the "Isolated" network policy: all traffic is +// denied by default except in-namespace, DNS, metrics scraping and ingress. Use it for +// tests that specifically assert behaviour under network isolation. func NewIsolatedProject(prefix, basePrefix string) *dv1alpha2.Project { + return newProject(prefix, basePrefix, "Isolated") +} + +// NewNonIsolatedProject builds a Project with the "NotRestricted" network policy: all +// traffic is allowed by default. Use it for tests that boot VirtualMachines whose guests +// need outbound access (e.g. cloud-init installing the qemu-guest-agent over the network); +// the "Isolated" policy would block that and the guest agent would never become ready. +func NewNonIsolatedProject(prefix, basePrefix string) *dv1alpha2.Project { + return newProject(prefix, basePrefix, "NotRestricted") +} + +func newProject(prefix, basePrefix, networkPolicy string) *dv1alpha2.Project { return &dv1alpha2.Project{ TypeMeta: metav1.TypeMeta{ APIVersion: "deckhouse.io/v1alpha2", @@ -43,7 +58,7 @@ func NewIsolatedProject(prefix, basePrefix string) *dv1alpha2.Project { "memory": "20Gi", }, }, - "networkPolicy": "Isolated", + "networkPolicy": networkPolicy, }, }, } diff --git a/test/e2e/internal/observer/cvi/observer.go b/test/e2e/internal/observer/cvi/observer.go new file mode 100644 index 0000000000..e5059d7f58 --- /dev/null +++ b/test/e2e/internal/observer/cvi/observer.go @@ -0,0 +1,80 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package cvi provides a ClusterVirtualImage-specialized observer. +package cvi + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +type Observer = observer.Observer[*v1alpha2.ClusterVirtualImage] + +type Predicate = observer.Predicate[*v1alpha2.ClusterVirtualImage] + +// StartObserver starts a ClusterVirtualImage Observer and registers a +// DeferCleanup that stops the watch and re-asserts no invariant was +// violated. In addition, a watcher goroutine surfaces the very first +// Never/Always violation through Ginkgo's Fail the moment it fires, so the +// test fails at the precise instant of the breach instead of blocking on a +// subsequent unrelated WaitFor and only reporting the violation in +// DeferCleanup. +func StartObserver(ctx context.Context, f *framework.Framework, cvi *v1alpha2.ClusterVirtualImage) Observer { + GinkgoHelper() + + obs, err := observer.New[*v1alpha2.ClusterVirtualImage]( + ctx, + f.VirtClient().ClusterVirtualImages(), + cvi.Name, + cvi.Namespace, + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for ClusterVirtualImage %s", cvi.Name) + + go failFastOnInvariant(obs, fmt.Sprintf("ClusterVirtualImage %s", cvi.Name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "ClusterVirtualImage %s observer reported an invariant violation", + cvi.Name) + }) + + return obs +} + +// failFastOnInvariant blocks until obs either reports an invariant +// violation or stops cleanly, and surfaces the first violation as a +// Ginkgo failure right away. It is meant to be launched in its own +// goroutine; defer GinkgoRecover() lets Fail's panic be captured by +// Ginkgo even though we are off the spec's main goroutine. +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/cvi/predicate.go b/test/e2e/internal/observer/cvi/predicate.go new file mode 100644 index 0000000000..173c0c36ed --- /dev/null +++ b/test/e2e/internal/observer/cvi/predicate.go @@ -0,0 +1,157 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cvi + +import ( + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/cvicondition" +) + +// quotaExceededMessagePrefix is the prefix the controller prepends to +// the Ready condition message when the project quota is exhausted. +const quotaExceededMessagePrefix = "Quota exceeded" + +func BeFailed() Predicate { + return func(i *v1alpha2.ClusterVirtualImage) (bool, error) { + if i.Status.Phase == v1alpha2.ImageFailed { + return true, fmt.Errorf("ClusterVirtualImage entered Failed phase") + } + if cond := findCondition(i.Status.Conditions, cvicondition.ReadyType.String()); cond != nil { + if isConditionFresh(cond, i) && cond.Reason == cvicondition.ProvisioningFailed.String() { + return true, fmt.Errorf("ready condition reports ProvisioningFailed: %s", cond.Message) + } + } + return false, nil + } +} + +func BeReady() Predicate { + return func(i *v1alpha2.ClusterVirtualImage) (bool, error) { + readyCond := findCondition(i.Status.Conditions, cvicondition.ReadyType.String()) + + condStale := readyCond != nil && !isConditionFresh(readyCond, i) + condIsReady := readyCond != nil && + !condStale && + readyCond.Status == metav1.ConditionTrue && + readyCond.Reason == cvicondition.Ready.String() + phaseIsReady := i.Status.Phase == v1alpha2.ImageReady + + switch { + case phaseIsReady && condStale: + return false, nil + case phaseIsReady && !condIsReady: + return false, fmt.Errorf( + "phase is Ready but Ready condition is %s/%s (message: %q), expected True/%s", + condStatus(readyCond), condReason(readyCond), condMessage(readyCond), cvicondition.Ready, + ) + case condIsReady && !phaseIsReady: + return false, fmt.Errorf( + "ready condition is True/%s but phase is %q, expected %q", + cvicondition.Ready, i.Status.Phase, v1alpha2.ImageReady, + ) + case !phaseIsReady: + return false, nil + } + + return true, nil + } +} + +// BeQuotaExceeded reports the ClusterVirtualImage has been parked in a +// quota-exhausted state. +// +// The predicate is satisfied when the Ready condition is fresh, +// reports Status=False with Reason=ProvisioningFailed, the message is +// prefixed with "Quota exceeded" (the controller wraps the upstream +// "exceeded quota:" Kubernetes error into a "Quota exceeded:" message), +// and the phase is Failed. +// +// Returned values: +// - (true, nil) - the ClusterVirtualImage reports a fresh +// quota-exceeded Ready condition together with the Failed phase; +// - (false, nil) - the controller has not yet reported a fresh +// quota-exceeded Ready condition; +// - (false, err) - the quota-exceeded message is reported with an +// unexpected phase or Status, which is a controller bug. +// +// Intended for use with [Observer.WaitFor]. +func BeQuotaExceeded() Predicate { + return func(i *v1alpha2.ClusterVirtualImage) (bool, error) { + cond := findCondition(i.Status.Conditions, cvicondition.ReadyType.String()) + if cond == nil || !isConditionFresh(cond, i) { + return false, nil + } + if cond.Reason != cvicondition.ProvisioningFailed.String() { + return false, nil + } + if !strings.HasPrefix(cond.Message, quotaExceededMessagePrefix) { + return false, nil + } + if cond.Status != metav1.ConditionFalse { + return false, fmt.Errorf( + "ready condition reports a quota-exceeded ProvisioningFailed but status is %s, expected %s", + cond.Status, metav1.ConditionFalse, + ) + } + if i.Status.Phase != v1alpha2.ImageFailed { + return false, fmt.Errorf( + "ready condition reports a quota-exceeded ProvisioningFailed but phase is %q, expected %q", + i.Status.Phase, v1alpha2.ImageFailed, + ) + } + return true, nil + } +} + +func isConditionFresh(cond *metav1.Condition, i *v1alpha2.ClusterVirtualImage) bool { + return cond.ObservedGeneration == i.GetGeneration() +} + +func findCondition(conds []metav1.Condition, condType string) *metav1.Condition { + for i := range conds { + if conds[i].Type == condType { + return &conds[i] + } + } + return nil +} + +func condStatus(cond *metav1.Condition) metav1.ConditionStatus { + if cond == nil { + return "" + } + return cond.Status +} + +func condReason(cond *metav1.Condition) string { + if cond == nil { + return "" + } + return cond.Reason +} + +func condMessage(cond *metav1.Condition) string { + if cond == nil { + return "" + } + return cond.Message +} diff --git a/test/e2e/internal/observer/deleted.go b/test/e2e/internal/observer/deleted.go new file mode 100644 index 0000000000..9599c80a5c --- /dev/null +++ b/test/e2e/internal/observer/deleted.go @@ -0,0 +1,142 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package observer + +import ( + "context" + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" +) + +const deletedPollInterval = time.Second + +// IsDeleted reports whether the resource identified by (name, namespace) no +// longer exists. When provided to WaitForDeleted, it is polled alongside the +// watch so fast deletions that happen before the watch starts are not missed. +type IsDeleted func(ctx context.Context) (bool, error) + +// WaitForDeleted blocks until a watch.Deleted event is observed for the +// resource identified by (name, namespace), or isDeleted reports that it is +// already gone. +func WaitForDeleted( + ctx context.Context, + w Watcher, + name, namespace string, + timeout time.Duration, + isDeleted IsDeleted, +) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if isDeleted != nil { + gone, err := isDeleted(ctx) + if err != nil { + return fmt.Errorf("observer: check deletion of %s/%s: %w", namespace, name, err) + } + if gone { + return nil + } + } + + wi, err := w.Watch(ctx, metav1.ListOptions{ + FieldSelector: "metadata.name=" + name, + }) + if err != nil { + return fmt.Errorf("observer: start watch for deletion of %s/%s: %w", namespace, name, err) + } + defer wi.Stop() + + poll := time.NewTicker(deletedPollInterval) + defer poll.Stop() + + for { + select { + case <-ctx.Done(): + if isDeleted != nil { + gone, err := isDeleted(ctx) + if err != nil { + return fmt.Errorf("observer: check deletion of %s/%s: %w", namespace, name, err) + } + if gone { + return nil + } + } + return fmt.Errorf("observer: wait for deletion of %s/%s timed out after %s: %w", namespace, name, timeout, ctx.Err()) + case <-poll.C: + if isDeleted == nil { + continue + } + gone, err := isDeleted(ctx) + if err != nil { + return fmt.Errorf("observer: check deletion of %s/%s: %w", namespace, name, err) + } + if gone { + return nil + } + case event, ok := <-wi.ResultChan(): + if !ok { + if isDeleted != nil { + gone, err := isDeleted(ctx) + if err != nil { + return fmt.Errorf("observer: check deletion of %s/%s: %w", namespace, name, err) + } + if gone { + return nil + } + } + return fmt.Errorf("observer: watch closed before %s/%s was deleted", namespace, name) + } + if event.Type != watch.Deleted { + continue + } + obj, ok := event.Object.(metav1.Object) + if !ok { + continue + } + if obj.GetName() == name && obj.GetNamespace() == namespace { + return nil + } + } + } +} + +// DynamicWatcher returns a Watcher for a dynamic API resource. +func DynamicWatcher(client dynamic.Interface, gvr schema.GroupVersionResource, namespace string) Watcher { + return &dynamicResourceWatcher{ + client: client, + gvr: gvr, + namespace: namespace, + } +} + +type dynamicResourceWatcher struct { + client dynamic.Interface + gvr schema.GroupVersionResource + namespace string +} + +func (d *dynamicResourceWatcher) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + if d.namespace != "" { + return d.client.Resource(d.gvr).Namespace(d.namespace).Watch(ctx, opts) + } + return d.client.Resource(d.gvr).Watch(ctx, opts) +} diff --git a/test/e2e/internal/observer/observer.go b/test/e2e/internal/observer/observer.go new file mode 100644 index 0000000000..aa8279e5a6 --- /dev/null +++ b/test/e2e/internal/observer/observer.go @@ -0,0 +1,387 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package observer provides a generic, watch-based utility for monitoring the +// lifecycle of a single Kubernetes resource. +// +// The observer subscribes to events for a particular (name, namespace) pair +// and exposes three primitives: +// +// - WaitFor blocks until the predicate is satisfied by the latest observed +// state or any event observed after the call, returns the predicate's +// diagnostic error, the timeout elapses, or one of the registered +// Always/Never invariants fires. +// - Never and Always register live predicates that are evaluated against +// every event recorded after the call, directly inside the watch loop. +// The very first violation is captured in Err and aborts every WaitFor +// currently in flight. +// +// Predicates use the [Predicate] signature, returning a (bool, error) tuple: +// +// - (true, nil) - the predicate is satisfied by the current state. +// - (false, nil) - the predicate is not satisfied yet. WaitFor keeps +// waiting; an Always invariant treats this as a violation; a Never +// invariant treats this as ok. +// - (_, err) - the predicate detected a definite, irrecoverable +// inconsistency. The error is propagated to WaitFor or used as the +// diagnostic for an Always/Never invariant violation. +package observer + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +// Watcher is the minimum interface required to start a watch on a Kubernetes +// resource. It matches typed clients generated by client-gen and the helpers +// already used by the e2e suite. +type Watcher interface { + Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) +} + +// Object is the type constraint for resources that can be observed. The +// observed type must be a pointer that implements metav1.Object so that the +// observer can filter watch events by name and namespace. +type Object interface { + metav1.Object +} + +// Predicate evaluates an observed state of T. See the package documentation +// for the meaning of the (bool, error) return value. +type Predicate[T Object] func(T) (bool, error) + +// Observer monitors a single Kubernetes resource through a watch and exposes +// invariant helpers over the sequence of states it observes. +// +// Implementations are safe for concurrent use. +type Observer[T Object] interface { + // Never registers a forbidden predicate. While the observer is running, + // every event is evaluated; the first time the predicate returns + // (true, _) or (_, err) the observer captures the violation in Err and + // aborts every WaitFor currently in flight. Never returns immediately and + // does not consult past history: only events recorded after the call are + // evaluated. + Never(predicate Predicate[T]) + // Always registers an invariant predicate. While the observer is + // running, every event is evaluated; the first time the predicate + // returns (false, _) or (_, err) the observer captures the violation in + // Err and aborts every WaitFor currently in flight. Always returns + // immediately and does not consult past history: only events recorded + // after the call are evaluated. + Always(predicate Predicate[T]) + // WaitFor returns nil as soon as the predicate returns (true, nil). It + // first evaluates the most recently observed state (so an + // already-satisfied condition returns immediately, even if no further + // event arrives), then every event observed after the call. It returns an + // error if the predicate returns a non-nil error, the timeout elapses, the + // observer is stopped, or one of the registered invariants is violated. + WaitFor(predicate Predicate[T], timeout time.Duration) error + // Err returns the first invariant violation captured by Never or Always, + // or nil if no invariant has been violated. Once Err returns a non-nil + // error, no further events are evaluated against any registered + // invariant. + Err() error + // InvariantViolated returns a channel that is closed as soon as a Never + // or Always predicate captures the first violation. It lets external + // watchers (e.g. a fail-fast goroutine that calls Ginkgo's Fail) react + // the instant a contract is broken, instead of waiting until the next + // WaitFor or the observer's deferred cleanup. + InvariantViolated() <-chan struct{} + // Stopped returns a channel that is closed when the observer has fully + // stopped (either because Stop was called or because the underlying + // watch terminated). External watchers use it to exit cleanly when no + // invariant violation has occurred. + Stopped() <-chan struct{} + // Stop terminates the underlying watch and unblocks every pending WaitFor + // call. Stop is idempotent and safe to call concurrently from multiple + // goroutines. + Stop() +} + +// New starts a watch via w and observes events for the resource identified by +// (name, namespace). The returned Observer is already running; the caller must +// invoke Stop to release the underlying watch. +// +// The watch is started before any caller-visible action takes place, which +// makes the function suitable for capturing the very first events emitted for +// a resource that the test is about to create. +func New[T Object]( + parentCtx context.Context, + w Watcher, + name, namespace string, +) (Observer[T], error) { + if w == nil { + return nil, errors.New("observer: watcher is nil") + } + + ctx, cancel := context.WithCancel(parentCtx) + wi, err := w.Watch(ctx, metav1.ListOptions{}) + if err != nil { + cancel() + return nil, fmt.Errorf("observer: start watch for %s/%s: %w", namespace, name, err) + } + + o := &observer[T]{ + name: name, + namespace: namespace, + listeners: make(map[chan T]struct{}), + invariantViolated: make(chan struct{}), + stop: make(chan struct{}), + done: make(chan struct{}), + } + + go o.run(wi, cancel) + + return o, nil +} + +type observer[T Object] struct { + name string + namespace string + + mu sync.Mutex + listeners map[chan T]struct{} + latest T + hasLatest bool + + invMu sync.Mutex + neverPredicates []Predicate[T] + alwaysPredicates []Predicate[T] + firstErr error + invariantViolated chan struct{} + invariantOnce sync.Once + + stop chan struct{} + stopOnce sync.Once + done chan struct{} +} + +func (o *observer[T]) run(wi watch.Interface, cancel context.CancelFunc) { + defer close(o.done) + defer wi.Stop() + defer cancel() + + for { + select { + case <-o.stop: + return + case event, ok := <-wi.ResultChan(): + if !ok { + return + } + obj, ok := event.Object.(T) + if !ok { + continue + } + if obj.GetName() != o.name || obj.GetNamespace() != o.namespace { + continue + } + o.broadcast(obj) + o.checkInvariants(obj) + } + } +} + +// broadcast records the latest observation and forwards it to every active +// WaitFor listener. +func (o *observer[T]) broadcast(obj T) { + o.mu.Lock() + defer o.mu.Unlock() + + o.latest = obj + o.hasLatest = true + + for ch := range o.listeners { + select { + case ch <- obj: + default: + // Listener buffer is full; drop the event for this listener. + // WaitFor predicates are typically simple and never block, so + // this branch should not be reached in practice. + } + } +} + +// checkInvariants evaluates every registered Never/Always predicate against +// the latest observation. The first violation latches firstErr and closes +// invariantViolated, which causes any WaitFor currently in flight to abort +// with the captured error. +// +// Predicates are evaluated sequentially inside the watch goroutine so that +// stateful closures see events in the order they were emitted by the API +// server. After the first violation no further predicates are evaluated for +// any subsequent event. +func (o *observer[T]) checkInvariants(obj T) { + o.invMu.Lock() + defer o.invMu.Unlock() + + if o.firstErr != nil { + return + } + + for _, p := range o.neverPredicates { + ok, err := p(obj) + if err != nil { + o.firstErr = fmt.Errorf("observer: Never predicate: %w", err) + o.signalViolationLocked() + return + } + if ok { + o.firstErr = errors.New("observer: Never predicate matched a live observation") + o.signalViolationLocked() + return + } + } + for _, p := range o.alwaysPredicates { + ok, err := p(obj) + if err != nil { + o.firstErr = fmt.Errorf("observer: Always predicate: %w", err) + o.signalViolationLocked() + return + } + if !ok { + o.firstErr = errors.New("observer: Always predicate did not hold for a live observation") + o.signalViolationLocked() + return + } + } +} + +// signalViolationLocked closes the invariantViolated channel exactly once. +// Must be called with invMu held. +func (o *observer[T]) signalViolationLocked() { + o.invariantOnce.Do(func() { + close(o.invariantViolated) + }) +} + +func (o *observer[T]) Never(predicate Predicate[T]) { + o.invMu.Lock() + defer o.invMu.Unlock() + o.neverPredicates = append(o.neverPredicates, predicate) +} + +func (o *observer[T]) Always(predicate Predicate[T]) { + o.invMu.Lock() + defer o.invMu.Unlock() + o.alwaysPredicates = append(o.alwaysPredicates, predicate) +} + +func (o *observer[T]) Err() error { + o.invMu.Lock() + defer o.invMu.Unlock() + return o.firstErr +} + +func (o *observer[T]) InvariantViolated() <-chan struct{} { + return o.invariantViolated +} + +func (o *observer[T]) Stopped() <-chan struct{} { + return o.done +} + +func (o *observer[T]) WaitFor(predicate Predicate[T], timeout time.Duration) error { + // If an invariant has already fired, abort immediately. + if err := o.Err(); err != nil { + return fmt.Errorf("observer: WaitFor aborted by invariant: %w", err) + } + + // If the observer has already been stopped, no future events will arrive. + select { + case <-o.done: + return errors.New("observer: WaitFor: observer is stopped") + default: + } + + o.mu.Lock() + ch := make(chan T, 256) + o.listeners[ch] = struct{}{} + latest := o.latest + hasLatest := o.hasLatest + o.mu.Unlock() + + defer func() { + o.mu.Lock() + delete(o.listeners, ch) + o.mu.Unlock() + }() + + // Evaluate the most recently observed state first: the predicate may + // already be satisfied (e.g. a disk that became Ready before this call), + // in which case no further watch event would arrive to unblock the loop + // below. The listener registered above still catches any newer event. + if hasLatest { + ok, err := predicate(latest) + if err != nil { + return fmt.Errorf("observer: WaitFor predicate: %w", err) + } + // An Always/Never predicate may have latched on the same event that + // satisfies this one. The invariant violation takes precedence: + // returning nil here would hide the breach until the observer is + // stopped. Re-check Err() before reporting success. + if invErr := o.Err(); invErr != nil { + return fmt.Errorf("observer: WaitFor aborted by invariant: %w", invErr) + } + if ok { + return nil + } + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case obj := <-ch: + ok, err := predicate(obj) + if err != nil { + return fmt.Errorf("observer: WaitFor predicate: %w", err) + } + // run() invokes broadcast (which delivers the event into ch) + // synchronously before checkInvariants. If an invariant fires on + // the same event, the listener and invariantViolated select cases + // can race; Go's select then picks pseudo-randomly between them. + // Re-check Err() here so the invariant takes precedence over a + // successful predicate match on the very same event. + if invErr := o.Err(); invErr != nil { + return fmt.Errorf("observer: WaitFor aborted by invariant: %w", invErr) + } + if ok { + return nil + } + case <-timer.C: + return fmt.Errorf("observer: WaitFor timed out after %s", timeout) + case <-o.done: + return errors.New("observer: WaitFor: observer stopped before predicate became true") + case <-o.invariantViolated: + return fmt.Errorf("observer: WaitFor aborted by invariant: %w", o.Err()) + } + } +} + +func (o *observer[T]) Stop() { + o.stopOnce.Do(func() { + close(o.stop) + }) + <-o.done +} diff --git a/test/e2e/internal/observer/pod/observer.go b/test/e2e/internal/observer/pod/observer.go new file mode 100644 index 0000000000..e7c342de2c --- /dev/null +++ b/test/e2e/internal/observer/pod/observer.go @@ -0,0 +1,68 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package pod provides a Pod-specialized observer. +package pod + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +type Observer = observer.Observer[*corev1.Pod] + +type Predicate = observer.Predicate[*corev1.Pod] + +func StartObserver(ctx context.Context, f *framework.Framework, pod *corev1.Pod) Observer { + GinkgoHelper() + + obs, err := observer.New[*corev1.Pod]( + ctx, + f.KubeClient().CoreV1().Pods(pod.Namespace), + pod.Name, + pod.Namespace, + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for Pod %s/%s", pod.Namespace, pod.Name) + + go failFastOnInvariant(obs, fmt.Sprintf("Pod %s/%s", pod.Namespace, pod.Name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "Pod %s/%s observer reported an invariant violation", + pod.Namespace, pod.Name) + }) + + return obs +} + +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/pod/predicate.go b/test/e2e/internal/observer/pod/predicate.go new file mode 100644 index 0000000000..bb69d8b50d --- /dev/null +++ b/test/e2e/internal/observer/pod/predicate.go @@ -0,0 +1,55 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pod + +import ( + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" +) + +func BeSucceeded() Predicate { + return func(pod *corev1.Pod) (bool, error) { + return pod.Status.Phase == corev1.PodSucceeded, nil + } +} + +func BeFailed() Predicate { + return func(pod *corev1.Pod) (bool, error) { + if pod.Status.Phase == corev1.PodFailed { + return true, fmt.Errorf("pod entered Failed phase: %s", containerDiagnostics(pod)) + } + return false, nil + } +} + +func containerDiagnostics(pod *corev1.Pod) string { + parts := make([]string, 0, len(pod.Status.ContainerStatuses)) + for _, status := range pod.Status.ContainerStatuses { + switch { + case status.State.Waiting != nil: + parts = append(parts, fmt.Sprintf("%s waiting %s: %s", status.Name, status.State.Waiting.Reason, status.State.Waiting.Message)) + case status.State.Terminated != nil: + parts = append(parts, fmt.Sprintf("%s terminated %s(%d): %s", status.Name, status.State.Terminated.Reason, status.State.Terminated.ExitCode, status.State.Terminated.Message)) + } + } + if len(parts) == 0 { + return "no container diagnostics" + } + return strings.Join(parts, "; ") +} diff --git a/test/e2e/internal/observer/pvc/observer.go b/test/e2e/internal/observer/pvc/observer.go new file mode 100644 index 0000000000..998f89e3db --- /dev/null +++ b/test/e2e/internal/observer/pvc/observer.go @@ -0,0 +1,68 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package pvc provides a PersistentVolumeClaim-specialized observer. +package pvc + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +type Observer = observer.Observer[*corev1.PersistentVolumeClaim] + +type Predicate = observer.Predicate[*corev1.PersistentVolumeClaim] + +func StartObserver(ctx context.Context, f *framework.Framework, claim *corev1.PersistentVolumeClaim) Observer { + GinkgoHelper() + + obs, err := observer.New[*corev1.PersistentVolumeClaim]( + ctx, + f.KubeClient().CoreV1().PersistentVolumeClaims(claim.Namespace), + claim.Name, + claim.Namespace, + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for PersistentVolumeClaim %s/%s", claim.Namespace, claim.Name) + + go failFastOnInvariant(obs, fmt.Sprintf("PersistentVolumeClaim %s/%s", claim.Namespace, claim.Name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "PersistentVolumeClaim %s/%s observer reported an invariant violation", + claim.Namespace, claim.Name) + }) + + return obs +} + +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/pvc/predicate.go b/test/e2e/internal/observer/pvc/predicate.go new file mode 100644 index 0000000000..3c8a78125f --- /dev/null +++ b/test/e2e/internal/observer/pvc/predicate.go @@ -0,0 +1,47 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pvc + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" +) + +func BeBound() Predicate { + return func(pvc *corev1.PersistentVolumeClaim) (bool, error) { + return pvc.Status.Phase == corev1.ClaimBound, nil + } +} + +func BeBoundAndPopulated() Predicate { + return func(pvc *corev1.PersistentVolumeClaim) (bool, error) { + return pvc.Status.Phase == corev1.ClaimBound && + pvc.Annotations[annotations.AnnPVCPopulationDone] == "true", nil + } +} + +func BeLost() Predicate { + return func(pvc *corev1.PersistentVolumeClaim) (bool, error) { + if pvc.Status.Phase == corev1.ClaimLost { + return true, fmt.Errorf("PersistentVolumeClaim entered Lost phase") + } + return false, nil + } +} diff --git a/test/e2e/internal/observer/resourcequota/observer.go b/test/e2e/internal/observer/resourcequota/observer.go new file mode 100644 index 0000000000..9e7bb04319 --- /dev/null +++ b/test/e2e/internal/observer/resourcequota/observer.go @@ -0,0 +1,68 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package resourcequota provides a ResourceQuota-specialized observer. +package resourcequota + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +type Observer = observer.Observer[*corev1.ResourceQuota] + +type Predicate = observer.Predicate[*corev1.ResourceQuota] + +func StartObserver(ctx context.Context, f *framework.Framework, quota *corev1.ResourceQuota) Observer { + GinkgoHelper() + + obs, err := observer.New[*corev1.ResourceQuota]( + ctx, + f.KubeClient().CoreV1().ResourceQuotas(quota.Namespace), + quota.Name, + quota.Namespace, + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for ResourceQuota %s/%s", quota.Namespace, quota.Name) + + go failFastOnInvariant(obs, fmt.Sprintf("ResourceQuota %s/%s", quota.Namespace, quota.Name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "ResourceQuota %s/%s observer reported an invariant violation", + quota.Namespace, quota.Name) + }) + + return obs +} + +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/resourcequota/predicate.go b/test/e2e/internal/observer/resourcequota/predicate.go new file mode 100644 index 0000000000..1a859cb674 --- /dev/null +++ b/test/e2e/internal/observer/resourcequota/predicate.go @@ -0,0 +1,37 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resourcequota + +import ( + corev1 "k8s.io/api/core/v1" +) + +// BeEnforced reports that the ResourceQuota status reflects the hard limits +// from spec, so admission-time enforcement is in effect. +func BeEnforced() Predicate { + return func(rq *corev1.ResourceQuota) (bool, error) { + pods, ok := rq.Status.Hard[corev1.ResourceName("count/pods")] + if !ok || pods.Sign() != 0 { + return false, nil + } + pvcs, ok := rq.Status.Hard[corev1.ResourceName("count/persistentvolumeclaims")] + if !ok || pvcs.Sign() != 0 { + return false, nil + } + return true, nil + } +} diff --git a/test/e2e/internal/observer/storageclass/observer.go b/test/e2e/internal/observer/storageclass/observer.go new file mode 100644 index 0000000000..79eb270a60 --- /dev/null +++ b/test/e2e/internal/observer/storageclass/observer.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package storageclass provides a StorageClass-specialized observer. +package storageclass + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + storagev1 "k8s.io/api/storage/v1" + + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +type Observer = observer.Observer[*storagev1.StorageClass] + +type Predicate = observer.Predicate[*storagev1.StorageClass] + +func StartObserver(ctx context.Context, f *framework.Framework, name string) Observer { + GinkgoHelper() + + obs, err := observer.New[*storagev1.StorageClass]( + ctx, + f.KubeClient().StorageV1().StorageClasses(), + name, + "", + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for StorageClass %q", name) + + go failFastOnInvariant(obs, fmt.Sprintf("StorageClass %q", name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "StorageClass %q observer reported an invariant violation", name) + }) + + return obs +} + +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/storageclass/predicate.go b/test/e2e/internal/observer/storageclass/predicate.go new file mode 100644 index 0000000000..b831f50cce --- /dev/null +++ b/test/e2e/internal/observer/storageclass/predicate.go @@ -0,0 +1,26 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package storageclass + +import storagev1 "k8s.io/api/storage/v1" + +// BeAvailable reports that the StorageClass exists and has not been deleted. +func BeAvailable() Predicate { + return func(sc *storagev1.StorageClass) (bool, error) { + return sc.UID != "" && sc.DeletionTimestamp == nil, nil + } +} diff --git a/test/e2e/internal/observer/storageprofile/observer.go b/test/e2e/internal/observer/storageprofile/observer.go new file mode 100644 index 0000000000..98ca4abb6d --- /dev/null +++ b/test/e2e/internal/observer/storageprofile/observer.go @@ -0,0 +1,69 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package storageprofile provides a StorageProfile-specialized observer. +package storageprofile + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + cdiv1beta1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" + + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" + "github.com/deckhouse/virtualization/test/e2e/internal/rewrite" +) + +type Observer = observer.Observer[*cdiv1beta1.StorageProfile] + +type Predicate = observer.Predicate[*cdiv1beta1.StorageProfile] + +func StartObserver(ctx context.Context, f *framework.Framework, name string) Observer { + GinkgoHelper() + + gvr := rewrite.StorageProfile{}.GVR() + w := &dynamicWatcher{ + client: f.DynamicClient(), + gvr: gvr, + } + + obs, err := observer.New[*cdiv1beta1.StorageProfile](ctx, w, name, "") + Expect(err).NotTo(HaveOccurred(), "failed to start observer for StorageProfile %q", name) + + go failFastOnInvariant(obs, fmt.Sprintf("StorageProfile %q", name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "StorageProfile %q observer reported an invariant violation", name) + }) + + return obs +} + +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/storageprofile/predicate.go b/test/e2e/internal/observer/storageprofile/predicate.go new file mode 100644 index 0000000000..7437bcc2f4 --- /dev/null +++ b/test/e2e/internal/observer/storageprofile/predicate.go @@ -0,0 +1,45 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package storageprofile + +import ( + "fmt" + + cdiv1beta1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" +) + +// BeReady reports that the StorageProfile is populated for storageClassName. +func BeReady(storageClassName string) Predicate { + return func(sp *cdiv1beta1.StorageProfile) (bool, error) { + if sp == nil { + return false, nil + } + if sp.Status.StorageClass == nil { + return false, nil + } + if *sp.Status.StorageClass != storageClassName { + return false, fmt.Errorf( + "StorageProfile %q references StorageClass %q, expected %q", + sp.Name, *sp.Status.StorageClass, storageClassName, + ) + } + if sp.Status.Provisioner == nil { + return false, nil + } + return true, nil + } +} diff --git a/test/e2e/internal/observer/storageprofile/watcher.go b/test/e2e/internal/observer/storageprofile/watcher.go new file mode 100644 index 0000000000..506400ba51 --- /dev/null +++ b/test/e2e/internal/observer/storageprofile/watcher.go @@ -0,0 +1,121 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package storageprofile + +import ( + "context" + "encoding/json" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + cdiv1beta1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" +) + +type dynamicWatcher struct { + client dynamic.Interface + gvr schema.GroupVersionResource +} + +func (w *dynamicWatcher) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + inner, err := w.client.Resource(w.gvr).Watch(ctx, opts) + if err != nil { + return nil, err + } + return newConvertingWatch(inner), nil +} + +type convertingWatch struct { + inner watch.Interface + events chan watch.Event + stop chan struct{} +} + +func newConvertingWatch(inner watch.Interface) watch.Interface { + cw := &convertingWatch{ + inner: inner, + events: make(chan watch.Event, 256), + stop: make(chan struct{}), + } + go cw.run() + return cw +} + +func (cw *convertingWatch) run() { + defer close(cw.events) + for { + select { + case <-cw.stop: + return + case event, ok := <-cw.inner.ResultChan(): + if !ok { + return + } + converted, err := convertStorageProfileEvent(event) + if err != nil { + continue + } + select { + case <-cw.stop: + return + case cw.events <- converted: + } + } + } +} + +func convertStorageProfileEvent(event watch.Event) (watch.Event, error) { + u, ok := event.Object.(*unstructured.Unstructured) + if !ok { + return event, nil + } + + sp, err := unstructuredToStorageProfile(u) + if err != nil { + return watch.Event{}, err + } + + return watch.Event{Type: event.Type, Object: sp}, nil +} + +func unstructuredToStorageProfile(u *unstructured.Unstructured) (*cdiv1beta1.StorageProfile, error) { + raw, err := u.MarshalJSON() + if err != nil { + return nil, err + } + + sp := &cdiv1beta1.StorageProfile{} + if err := json.Unmarshal(raw, sp); err != nil { + return nil, err + } + return sp, nil +} + +func (cw *convertingWatch) Stop() { + select { + case <-cw.stop: + default: + close(cw.stop) + } + cw.inner.Stop() +} + +func (cw *convertingWatch) ResultChan() <-chan watch.Event { + return cw.events +} diff --git a/test/e2e/internal/observer/vd/observer.go b/test/e2e/internal/observer/vd/observer.go new file mode 100644 index 0000000000..01d0a8b651 --- /dev/null +++ b/test/e2e/internal/observer/vd/observer.go @@ -0,0 +1,95 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package vd provides a VirtualDisk-specialized [observer.Observer] together +// with a curated set of predicates ready to be used with its Never, Always +// and WaitFor primitives. +package vd + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +// Observer is a convenience type alias for the generic Observer specialized +// for VirtualDisks. +type Observer = observer.Observer[*v1alpha2.VirtualDisk] + +// Predicate is a convenience type alias for the generic Predicate specialized +// for VirtualDisks. +type Predicate = observer.Predicate[*v1alpha2.VirtualDisk] + +// StartObserver starts a VirtualDisk Observer for the given disk and +// registers a DeferCleanup that: +// +// 1. stops the underlying watch, releasing the watcher resources; +// 2. asserts that no Never/Always invariant registered on the observer was +// violated during the test. +// +// The watch is started before the caller creates the VirtualDisk, ensuring +// that the very first phase transitions are captured and that any live +// invariants registered on the returned observer see every emitted event. +// +// In addition to the deferred assertion, a watcher goroutine surfaces the +// very first Never/Always violation through Ginkgo's Fail the moment it +// fires, so the test fails at the precise instant of the breach instead of +// blocking on a subsequent unrelated WaitFor (e.g. on a VirtualMachine that +// is still spinning up) and only reporting the violation in DeferCleanup. +func StartObserver(ctx context.Context, f *framework.Framework, vd *v1alpha2.VirtualDisk) Observer { + GinkgoHelper() + + obs, err := observer.New[*v1alpha2.VirtualDisk]( + ctx, + f.VirtClient().VirtualDisks(vd.Namespace), + vd.Name, + vd.Namespace, + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for VirtualDisk %s/%s", vd.Namespace, vd.Name) + + go failFastOnInvariant(obs, fmt.Sprintf("VirtualDisk %s/%s", vd.Namespace, vd.Name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "VirtualDisk %s/%s observer reported an invariant violation", + vd.Namespace, vd.Name) + }) + + return obs +} + +// failFastOnInvariant blocks until obs either reports an invariant +// violation or stops cleanly, and surfaces the first violation as a +// Ginkgo failure right away. It is meant to be launched in its own +// goroutine; defer GinkgoRecover() lets Fail's panic be captured by +// Ginkgo even though we are off the spec's main goroutine. +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/vd/predicate.go b/test/e2e/internal/observer/vd/predicate.go new file mode 100644 index 0000000000..2f15f462b1 --- /dev/null +++ b/test/e2e/internal/observer/vd/predicate.go @@ -0,0 +1,533 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vd + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +// readyProgress is the value of VirtualDisk.Status.Progress when the disk +// has finished provisioning. +const readyProgress = "100%" + +// BeFailed reports an invariant violation when the VirtualDisk has reached +// the terminal Failed phase or its Ready condition reports the +// ProvisioningFailed reason. It is intended to be used with [Observer.Never]. +func BeFailed() Predicate { + return func(d *v1alpha2.VirtualDisk) (bool, error) { + if d.Status.Phase == v1alpha2.DiskFailed { + return true, fmt.Errorf("VirtualDisk entered Failed phase") + } + if cond := findCondition(d.Status.Conditions, vdcondition.ReadyType.String()); cond != nil { + if isConditionFresh(cond, d) && cond.Reason == vdcondition.ProvisioningFailed.String() { + return true, fmt.Errorf("ready condition reports ProvisioningFailed: %s", cond.Message) + } + } + return false, nil + } +} + +// BeStorageClassReady reports the StorageClassReady condition is healthy. +// +// The condition is treated as healthy when: +// - it is absent (the controller has not yet computed it); +// - it is stale, i.e. its observedGeneration does not match the resource +// generation (the test should wait for the controller to refresh it); +// - it has Status=True with Reason=StorageClassReady. +// +// Any other state is reported as a definite invariant violation. Intended +// for use with [Observer.Always]. +func BeStorageClassReady() Predicate { + return func(d *v1alpha2.VirtualDisk) (bool, error) { + cond := findCondition(d.Status.Conditions, vdcondition.StorageClassReadyType.String()) + if cond == nil || !isConditionFresh(cond, d) { + return true, nil + } + if cond.Status != metav1.ConditionTrue { + return false, fmt.Errorf( + "StorageClassReady condition is %s/%s (message: %q), expected True/%s", + cond.Status, cond.Reason, cond.Message, vdcondition.StorageClassReady, + ) + } + if cond.Reason != vdcondition.StorageClassReady.String() { + return false, fmt.Errorf( + "StorageClassReady reason is %q, expected %q", + cond.Reason, vdcondition.StorageClassReady, + ) + } + return true, nil + } +} + +// BeDataSourceReady reports the DatasourceReady condition is healthy. +// +// The condition is treated as healthy under the same rules as for +// [BeStorageClassReady] (absent, stale, or True/DatasourceReady). The +// controller legitimately removes this condition once the disk has reached +// the Ready phase, so an absent condition is always treated as healthy. +// Intended for use with [Observer.Always]. +func BeDataSourceReady() Predicate { + return func(d *v1alpha2.VirtualDisk) (bool, error) { + cond := findCondition(d.Status.Conditions, vdcondition.DatasourceReadyType.String()) + if cond == nil || !isConditionFresh(cond, d) { + return true, nil + } + if cond.Status != metav1.ConditionTrue { + return false, fmt.Errorf( + "DatasourceReady condition is %s/%s (message: %q), expected True/%s", + cond.Status, cond.Reason, cond.Message, vdcondition.DatasourceReady, + ) + } + if cond.Reason != vdcondition.DatasourceReady.String() { + return false, fmt.Errorf( + "DatasourceReady reason is %q, expected %q", + cond.Reason, vdcondition.DatasourceReady, + ) + } + return true, nil + } +} + +// BeReady reports the VirtualDisk is fully provisioned. +// +// The predicate is satisfied only when the phase, the Ready condition, the +// progress, the capacity, the target PVC name and the storage class name are +// all populated and consistent with each other. Intended for use with +// [Observer.WaitFor]. +// +// Returned values: +// - (true, nil) - the disk is ready and every status field is populated; +// - (false, nil) - the disk is still being provisioned or the Ready +// condition is stale; +// - (false, err) - the disk reports an internally inconsistent ready state +// (phase Ready without a matching Ready condition, or with a missing +// status field). The error fails the WaitFor immediately. +func BeReady() Predicate { + return func(d *v1alpha2.VirtualDisk) (bool, error) { + readyCond := findCondition(d.Status.Conditions, vdcondition.ReadyType.String()) + + condStale := readyCond != nil && !isConditionFresh(readyCond, d) + condIsReady := readyCond != nil && + !condStale && + readyCond.Status == metav1.ConditionTrue && + readyCond.Reason == vdcondition.Ready.String() + phaseIsReady := d.Status.Phase == v1alpha2.DiskReady + + switch { + case phaseIsReady && condStale: + // Wait for the controller to refresh the Ready condition. + return false, nil + case phaseIsReady && !condIsReady: + return false, fmt.Errorf( + "phase is Ready but Ready condition is %s/%s (message: %q), expected True/%s", + condStatus(readyCond), condReason(readyCond), condMessage(readyCond), vdcondition.Ready, + ) + case condIsReady && !phaseIsReady: + return false, fmt.Errorf( + "ready condition is True/%s but phase is %q, expected %q", + vdcondition.Ready, d.Status.Phase, v1alpha2.DiskReady, + ) + case !phaseIsReady: + return false, nil + } + + if d.Status.Progress != readyProgress { + return false, fmt.Errorf( + "phase is Ready but progress is %q, expected %q", + d.Status.Progress, readyProgress, + ) + } + if d.Status.Capacity == "" { + return false, errors.New("phase is Ready but capacity is empty") + } + if d.Status.Target.PersistentVolumeClaim == "" { + return false, errors.New("phase is Ready but target.persistentVolumeClaimName is empty") + } + if d.Status.StorageClassName == "" { + return false, errors.New("phase is Ready but storageClassName is empty") + } + + return true, nil + } +} + +// BeDetached reports the VirtualDisk is not attached to any VirtualMachine. +func BeDetached() Predicate { + return func(d *v1alpha2.VirtualDisk) (bool, error) { + return len(d.Status.AttachedToVirtualMachines) == 0, nil + } +} + +// BeWaitForFirstConsumer reports the VirtualDisk has parked in the +// WaitForFirstConsumer phase, waiting for a consumer (a VirtualMachine) to be +// scheduled before it can provision its volume. It is used to synchronize a disk +// on a WaitForFirstConsumer storage class before creating the VirtualMachine that +// consumes it. Intended for use with [Observer.WaitFor]. +func BeWaitForFirstConsumer() Predicate { + return func(d *v1alpha2.VirtualDisk) (bool, error) { + return d.Status.Phase == v1alpha2.DiskWaitForFirstConsumer, nil + } +} + +// BeQuotaExceeded reports the VirtualDisk has been parked in a +// quota-exhausted state. +// +// The predicate is satisfied when the Ready condition is fresh, +// reports Status=False with Reason=QuotaExceeded, and the phase is +// either Failed (importer/uploader Pod creation rejected by the +// project quota) or Pending (PVC creation rejected by the project +// quota). Any other phase together with a fresh Reason=QuotaExceeded +// is reported as a definite invariant violation. +// +// Returned values: +// - (true, nil) - the VirtualDisk reports a fresh quota-exceeded +// Ready condition together with a matching phase; +// - (false, nil) - the controller has not yet reported a fresh +// quota-exceeded Ready condition; +// - (false, err) - Reason=QuotaExceeded is reported with an +// unexpected phase or Status, which is a controller bug. +// +// Intended for use with [Observer.WaitFor]. +func BeQuotaExceeded() Predicate { + return func(d *v1alpha2.VirtualDisk) (bool, error) { + cond := findCondition(d.Status.Conditions, vdcondition.ReadyType.String()) + if cond == nil || !isConditionFresh(cond, d) { + return false, nil + } + if cond.Reason != vdcondition.QuotaExceeded.String() { + return false, nil + } + if cond.Status != metav1.ConditionFalse { + return false, fmt.Errorf( + "ready condition reason is %q but status is %s, expected %s", + cond.Reason, cond.Status, metav1.ConditionFalse, + ) + } + switch d.Status.Phase { + case v1alpha2.DiskFailed, v1alpha2.DiskPending: + return true, nil + default: + return false, fmt.Errorf( + "ready condition reason is %q but phase is %q, expected %q or %q", + cond.Reason, d.Status.Phase, v1alpha2.DiskFailed, v1alpha2.DiskPending, + ) + } + } +} + +// BeReadyForUserUpload reports the VirtualDisk has reached the +// WaitForUserUpload phase and exposes a usable external upload URL. +// +// Returned values: +// - (true, nil) - the disk is in WaitForUserUpload and has both upload +// URLs populated; +// - (false, nil) - the disk has not yet reached WaitForUserUpload; +// - (false, err) - the disk is in WaitForUserUpload but the upload URLs +// are missing or empty (a controller bug). +func BeReadyForUserUpload() Predicate { + return func(d *v1alpha2.VirtualDisk) (bool, error) { + if d.Status.Phase != v1alpha2.DiskWaitForUserUpload { + return false, nil + } + if d.Status.ImageUploadURLs == nil { + return false, errors.New("phase is WaitForUserUpload but ImageUploadURLs is nil") + } + if d.Status.ImageUploadURLs.External == "" { + return false, errors.New("phase is WaitForUserUpload but external upload URL is empty") + } + return true, nil + } +} + +// HaveValidPhaseTransitions reports an invariant violation when +// VirtualDisk.Status.Phase regresses to an earlier point of the +// provisioning lifecycle. +// +// The phases observed during provisioning are organized into ordered +// milestones: +// +// 0: "" (the controller has not yet computed a phase) +// 1: Pending +// 2: Provisioning, WaitForUserUpload, WaitForFirstConsumer +// 3: Ready +// +// Rank-2 phases are considered equivalent: Provisioning may flip to +// WaitForUserUpload or WaitForFirstConsumer (and back) while the +// controller waits for the user upload or for the first consumer. Once a +// higher milestone has been observed, the phase must not regress to a +// lower one. For example, observing "" or Pending after Provisioning, +// or any of the rank-0..2 phases after Ready, is a violation. +// +// Phases that are not part of the provisioning happy path (Failed, +// Terminating, PVCLost, Resizing, Migrating) are skipped: they are +// handled by other invariants (for example [BeFailed]) and may legally +// follow Ready in unrelated lifecycle scenarios. +// +// Intended for use with [Observer.Always]. +func HaveValidPhaseTransitions() Predicate { + var ( + maxRank int + maxPhase v1alpha2.DiskPhase + hasObserved bool + ) + + return func(d *v1alpha2.VirtualDisk) (bool, error) { + rank, known := diskPhaseRank(d.Status.Phase) + if !known { + return true, nil + } + + if hasObserved && rank < maxRank { + return false, fmt.Errorf( + "phase regressed from %s to %s", + displayPhase(maxPhase), displayPhase(d.Status.Phase), + ) + } + + if !hasObserved || rank > maxRank { + maxRank = rank + maxPhase = d.Status.Phase + } + hasObserved = true + return true, nil + } +} + +// diskPhaseRank returns the milestone rank of a VirtualDisk phase along +// the provisioning happy path. Phases outside that path are reported as +// unknown (false) so that callers can skip them. +func diskPhaseRank(phase v1alpha2.DiskPhase) (int, bool) { + switch phase { + case "": + return 0, true + case v1alpha2.DiskPending: + return 1, true + case v1alpha2.DiskProvisioning, + v1alpha2.DiskWaitForUserUpload, + v1alpha2.DiskWaitForFirstConsumer: + return 2, true + case v1alpha2.DiskReady: + return 3, true + default: + return 0, false + } +} + +func displayPhase(phase v1alpha2.DiskPhase) string { + if phase == "" { + return `""` + } + return fmt.Sprintf("%q", string(phase)) +} + +// ProgressExpectations describes which progress values a scenario must observe +// before the VirtualDisk reaches Ready. +type ProgressExpectations struct { + RequireZero bool + RequireBetweenZeroAndFifty bool + RequireBetweenFiftyAndHundred bool + RequireIntermediateExceptFifty bool + RequireHundred bool +} + +// HaveValidProgress enforces the common VirtualDisk progress contract and the +// scenario-specific coverage expectations. +func HaveValidProgress(expect ProgressExpectations, updateInterval, boundaryBudget time.Duration) Predicate { + var ( + previous *float64 + lastAdvance time.Time + observed progressObservations + ) + + return func(d *v1alpha2.VirtualDisk) (bool, error) { + if d.Status.Phase == v1alpha2.DiskPending && d.Status.Progress != "" { + return false, fmt.Errorf("phase is Pending but progress is %q, expected empty progress", d.Status.Progress) + } + if d.Status.Phase == v1alpha2.DiskReady && d.Status.Progress == "" { + return false, errors.New("phase is Ready but progress is empty, expected 100%") + } + if d.Status.Progress == "" { + return true, nil + } + + current, err := parseProgress(d.Status.Progress) + if err != nil { + return false, err + } + + if current == 100 && d.Status.Phase != v1alpha2.DiskReady { + return false, fmt.Errorf("progress is 100%% but phase is %s, expected Ready", displayPhase(d.Status.Phase)) + } + if d.Status.Phase == v1alpha2.DiskReady && current != 100 { + return false, fmt.Errorf("phase is Ready but progress is %q, expected 100%%", d.Status.Progress) + } + if previous != nil && current < *previous { + return false, fmt.Errorf("progress decreased from %s to %s", formatProgressValue(*previous), formatProgressValue(current)) + } + if previous != nil && current == *previous && current == 100 && d.Status.Phase == v1alpha2.DiskReady { + return observed.satisfies(expect) + } + + now := time.Now() + if previous != nil { + budget := updateInterval + if isProgressLongPauseValue(*previous) { + budget = boundaryBudget + } + if gap := now.Sub(lastAdvance); gap > budget { + return false, fmt.Errorf( + "progress stayed at %s for %s before %s; it must grow at least every %s (0%%, 50%% and 100%% may stay up to %s)", + formatProgressValue(*previous), gap.Round(time.Second), formatProgressValue(current), updateInterval, boundaryBudget, + ) + } + if current == *previous && d.Status.Phase != v1alpha2.DiskReady { + return true, nil + } + } + + observed.record(current) + previous = ¤t + lastAdvance = now + + if d.Status.Phase != v1alpha2.DiskReady { + return true, nil + } + return observed.satisfies(expect) + } +} + +type progressObservations struct { + hasZero bool + hasBetweenZeroAndFifty bool + hasBetweenFiftyAndHundred bool + hasIntermediateExceptFifty bool + hasHundred bool +} + +func (o *progressObservations) record(p float64) { + switch { + case p == 0: + o.hasZero = true + case p > 0 && p < 50: + o.hasBetweenZeroAndFifty = true + o.hasIntermediateExceptFifty = true + case p > 50 && p < 100: + o.hasBetweenFiftyAndHundred = true + o.hasIntermediateExceptFifty = true + case p > 0 && p < 100 && p != 50: + o.hasIntermediateExceptFifty = true + case p == 100: + o.hasHundred = true + } +} + +func (o progressObservations) satisfies(expect ProgressExpectations) (bool, error) { + switch { + case expect.RequireZero && !o.hasZero: + return false, errors.New("progress reached Ready without observing 0%") + case expect.RequireBetweenZeroAndFifty && !o.hasBetweenZeroAndFifty: + return false, errors.New("progress reached Ready without observing a value in (0%;50%)") + case expect.RequireBetweenFiftyAndHundred && !o.hasBetweenFiftyAndHundred: + return false, errors.New("progress reached Ready without observing a value in (50%;100%)") + case expect.RequireIntermediateExceptFifty && !o.hasIntermediateExceptFifty: + return false, errors.New("progress reached Ready without observing a value in (0%;100%) different from 50%") + case expect.RequireHundred && !o.hasHundred: + return false, errors.New("progress reached Ready without observing 100%") + default: + return true, nil + } +} + +// isProgressLongPauseValue reports whether p may legitimately stay unchanged +// for the longer progress budget. +func isProgressLongPauseValue(p float64) bool { + return p == 0 || p == 50 || p == 100 +} + +// formatProgressValue renders a parsed progress percentage the same way the +// controller does: 0%/100% without a fraction, everything else with one decimal. +func formatProgressValue(p float64) string { + switch p { + case 0: + return "0%" + case 100: + return "100%" + default: + return fmt.Sprintf("%.1f%%", p) + } +} + +func parseProgress(progress string) (float64, error) { + value := strings.TrimSuffix(progress, "%") + if value == progress { + return 0, fmt.Errorf("progress %q does not have %% suffix", progress) + } + + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("parse progress %q: %w", progress, err) + } + if parsed < 0 || parsed > 100 { + return 0, fmt.Errorf("progress %q is outside 0..100 range", progress) + } + return parsed, nil +} + +// isConditionFresh reports whether the condition has been computed against +// the latest observed generation of the resource. +func isConditionFresh(cond *metav1.Condition, d *v1alpha2.VirtualDisk) bool { + return cond.ObservedGeneration == d.GetGeneration() +} + +func findCondition(conds []metav1.Condition, condType string) *metav1.Condition { + for i := range conds { + if conds[i].Type == condType { + return &conds[i] + } + } + return nil +} + +func condStatus(cond *metav1.Condition) metav1.ConditionStatus { + if cond == nil { + return "" + } + return cond.Status +} + +func condReason(cond *metav1.Condition) string { + if cond == nil { + return "" + } + return cond.Reason +} + +func condMessage(cond *metav1.Condition) string { + if cond == nil { + return "" + } + return cond.Message +} diff --git a/test/e2e/internal/observer/vdsnapshot/observer.go b/test/e2e/internal/observer/vdsnapshot/observer.go new file mode 100644 index 0000000000..ec0085d3a0 --- /dev/null +++ b/test/e2e/internal/observer/vdsnapshot/observer.go @@ -0,0 +1,89 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package vdsnapshot provides a VirtualDiskSnapshot-specialized +// [observer.Observer] together with predicates ready to be used with its +// Never, Always and WaitFor primitives. +package vdsnapshot + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +// Observer is a convenience type alias for the generic Observer specialized +// for VirtualDiskSnapshots. +type Observer = observer.Observer[*v1alpha2.VirtualDiskSnapshot] + +// Predicate is a convenience type alias for the generic Predicate specialized +// for VirtualDiskSnapshots. +type Predicate = observer.Predicate[*v1alpha2.VirtualDiskSnapshot] + +// StartObserver starts a VirtualDiskSnapshot Observer for the given snapshot +// and registers a DeferCleanup that stops the underlying watch and asserts no +// registered invariant was violated. Start it before creating the snapshot so +// the very first phase transitions are captured. +// +// In addition to the deferred assertion, a watcher goroutine surfaces the +// very first Never/Always violation through Ginkgo's Fail the moment it +// fires, so the test fails at the precise instant of the breach instead of +// blocking on a subsequent unrelated WaitFor and only reporting the +// violation in DeferCleanup. +func StartObserver(ctx context.Context, f *framework.Framework, snapshot *v1alpha2.VirtualDiskSnapshot) Observer { + GinkgoHelper() + + obs, err := observer.New[*v1alpha2.VirtualDiskSnapshot]( + ctx, + f.VirtClient().VirtualDiskSnapshots(snapshot.Namespace), + snapshot.Name, + snapshot.Namespace, + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for VirtualDiskSnapshot %s/%s", snapshot.Namespace, snapshot.Name) + + go failFastOnInvariant(obs, fmt.Sprintf("VirtualDiskSnapshot %s/%s", snapshot.Namespace, snapshot.Name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "VirtualDiskSnapshot %s/%s observer reported an invariant violation", + snapshot.Namespace, snapshot.Name) + }) + + return obs +} + +// failFastOnInvariant blocks until obs either reports an invariant +// violation or stops cleanly, and surfaces the first violation as a +// Ginkgo failure right away. It is meant to be launched in its own +// goroutine; defer GinkgoRecover() lets Fail's panic be captured by +// Ginkgo even though we are off the spec's main goroutine. +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/vdsnapshot/predicate.go b/test/e2e/internal/observer/vdsnapshot/predicate.go new file mode 100644 index 0000000000..4a9b9097d4 --- /dev/null +++ b/test/e2e/internal/observer/vdsnapshot/predicate.go @@ -0,0 +1,40 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vdsnapshot + +import ( + "fmt" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +// BeReady reports the VirtualDiskSnapshot has finished and reached the Ready +// phase. A Failed phase is reported as a definite error so that any WaitFor +// caller fails immediately instead of waiting for the timeout. Intended for use +// with [Observer.WaitFor]. +func BeReady() Predicate { + return func(s *v1alpha2.VirtualDiskSnapshot) (bool, error) { + switch s.Status.Phase { + case v1alpha2.VirtualDiskSnapshotPhaseReady: + return true, nil + case v1alpha2.VirtualDiskSnapshotPhaseFailed: + return false, fmt.Errorf("VirtualDiskSnapshot entered Failed phase") + default: + return false, nil + } + } +} diff --git a/test/e2e/internal/observer/vi/observer.go b/test/e2e/internal/observer/vi/observer.go new file mode 100644 index 0000000000..d61ceb8f5c --- /dev/null +++ b/test/e2e/internal/observer/vi/observer.go @@ -0,0 +1,95 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package vi provides a VirtualImage-specialized [observer.Observer] together +// with a curated set of predicates ready to be used with its Never, Always +// and WaitFor primitives. +package vi + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +// Observer is a convenience type alias for the generic Observer specialized +// for VirtualImages. +type Observer = observer.Observer[*v1alpha2.VirtualImage] + +// Predicate is a convenience type alias for the generic Predicate specialized +// for VirtualImages. +type Predicate = observer.Predicate[*v1alpha2.VirtualImage] + +// StartObserver starts a VirtualImage Observer for the given image and +// registers a DeferCleanup that: +// +// 1. stops the underlying watch, releasing the watcher resources; +// 2. asserts that no Never/Always invariant registered on the observer was +// violated during the test. +// +// The watch is started before the caller creates the VirtualImage, ensuring +// that the very first phase transitions are captured and that any live +// invariants registered on the returned observer see every emitted event. +// +// In addition to the deferred assertion, a watcher goroutine surfaces the +// very first Never/Always violation through Ginkgo's Fail the moment it +// fires, so the test fails at the precise instant of the breach instead of +// blocking on a subsequent unrelated WaitFor and only reporting the +// violation in DeferCleanup. +func StartObserver(ctx context.Context, f *framework.Framework, vi *v1alpha2.VirtualImage) Observer { + GinkgoHelper() + + obs, err := observer.New[*v1alpha2.VirtualImage]( + ctx, + f.VirtClient().VirtualImages(vi.Namespace), + vi.Name, + vi.Namespace, + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for VirtualImage %s/%s", vi.Namespace, vi.Name) + + go failFastOnInvariant(obs, fmt.Sprintf("VirtualImage %s/%s", vi.Namespace, vi.Name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "VirtualImage %s/%s observer reported an invariant violation", + vi.Namespace, vi.Name) + }) + + return obs +} + +// failFastOnInvariant blocks until obs either reports an invariant +// violation or stops cleanly, and surfaces the first violation as a +// Ginkgo failure right away. It is meant to be launched in its own +// goroutine; defer GinkgoRecover() lets Fail's panic be captured by +// Ginkgo even though we are off the spec's main goroutine. +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/vi/predicate.go b/test/e2e/internal/observer/vi/predicate.go new file mode 100644 index 0000000000..5561fe841b --- /dev/null +++ b/test/e2e/internal/observer/vi/predicate.go @@ -0,0 +1,434 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vi + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vicondition" +) + +// BeFailed reports an invariant violation when the VirtualImage has reached +// the terminal Failed phase or its Ready condition reports the +// ProvisioningFailed reason. It is intended to be used with [Observer.Never]. +func BeFailed() Predicate { + return func(i *v1alpha2.VirtualImage) (bool, error) { + if i.Status.Phase == v1alpha2.ImageFailed { + return true, fmt.Errorf("VirtualImage entered Failed phase") + } + if cond := findCondition(i.Status.Conditions, vicondition.ReadyType.String()); cond != nil { + if isConditionFresh(cond, i) && cond.Reason == vicondition.ProvisioningFailed.String() { + return true, fmt.Errorf("ready condition reports ProvisioningFailed: %s", cond.Message) + } + } + return false, nil + } +} + +// HaveFormat reports an invariant violation when a Ready VirtualImage reports a +// status.format different from the expected on-disk format. +func HaveFormat(expected string) Predicate { + return func(i *v1alpha2.VirtualImage) (bool, error) { + if i.Status.Phase != v1alpha2.ImageReady { + return true, nil + } + if i.Status.Format != expected { + return false, fmt.Errorf("status.format is %q, expected %q", i.Status.Format, expected) + } + return true, nil + } +} + +// BeReady reports the VirtualImage has finished provisioning. +// +// The predicate is satisfied only when the phase is Ready and the Ready +// condition is True/Ready and is not stale. Inconsistencies (phase Ready +// without a fresh Ready condition matching it) produce a (false, error) +// pair so that any WaitFor caller fails immediately. Intended for use with +// [Observer.WaitFor]. +func BeReady() Predicate { + return func(i *v1alpha2.VirtualImage) (bool, error) { + readyCond := findCondition(i.Status.Conditions, vicondition.ReadyType.String()) + + condStale := readyCond != nil && !isConditionFresh(readyCond, i) + condIsReady := readyCond != nil && + !condStale && + readyCond.Status == metav1.ConditionTrue && + readyCond.Reason == vicondition.Ready.String() + phaseIsReady := i.Status.Phase == v1alpha2.ImageReady + + switch { + case phaseIsReady && condStale: + return false, nil + case phaseIsReady && !condIsReady: + return false, fmt.Errorf( + "phase is Ready but Ready condition is %s/%s (message: %q), expected True/%s", + condStatus(readyCond), condReason(readyCond), condMessage(readyCond), vicondition.Ready, + ) + case condIsReady && !phaseIsReady: + return false, fmt.Errorf( + "ready condition is True/%s but phase is %q, expected %q", + vicondition.Ready, i.Status.Phase, v1alpha2.ImageReady, + ) + case !phaseIsReady: + return false, nil + } + + return true, nil + } +} + +// quotaExceededMessagePrefix is the prefix the controller prepends to +// the Ready condition message when the project quota is exhausted. +const quotaExceededMessagePrefix = "Quota exceeded" + +// BeQuotaExceeded reports the VirtualImage has been parked in a +// quota-exhausted state. +// +// The predicate is satisfied when the Ready condition is fresh, +// reports Status=False with Reason=ProvisioningFailed, the message is +// prefixed with "Quota exceeded" (the controller wraps the upstream +// "exceeded quota:" Kubernetes error into a "Quota exceeded:" message), +// and the phase is Failed. +// +// Returned values: +// - (true, nil) - the VirtualImage reports a fresh quota-exceeded +// Ready condition together with the Failed phase; +// - (false, nil) - the controller has not yet reported a fresh +// quota-exceeded Ready condition; +// - (false, err) - the quota-exceeded message is reported with an +// unexpected phase or Status, which is a controller bug. +// +// Intended for use with [Observer.WaitFor]. +func BeQuotaExceeded() Predicate { + return func(i *v1alpha2.VirtualImage) (bool, error) { + cond := findCondition(i.Status.Conditions, vicondition.ReadyType.String()) + if cond == nil || !isConditionFresh(cond, i) { + return false, nil + } + if cond.Reason != vicondition.ProvisioningFailed.String() { + return false, nil + } + if !strings.HasPrefix(cond.Message, quotaExceededMessagePrefix) { + return false, nil + } + if cond.Status != metav1.ConditionFalse { + return false, fmt.Errorf( + "ready condition reports a quota-exceeded ProvisioningFailed but status is %s, expected %s", + cond.Status, metav1.ConditionFalse, + ) + } + if i.Status.Phase != v1alpha2.ImageFailed { + return false, fmt.Errorf( + "ready condition reports a quota-exceeded ProvisioningFailed but phase is %q, expected %q", + i.Status.Phase, v1alpha2.ImageFailed, + ) + } + return true, nil + } +} + +// BeReadyForUserUpload reports the VirtualImage has reached the +// WaitForUserUpload phase and exposes a usable external upload URL. +func BeReadyForUserUpload() Predicate { + return func(i *v1alpha2.VirtualImage) (bool, error) { + if i.Status.Phase != v1alpha2.ImageWaitForUserUpload { + return false, nil + } + if i.Status.ImageUploadURLs == nil { + return false, errors.New("phase is WaitForUserUpload but ImageUploadURLs is nil") + } + if i.Status.ImageUploadURLs.External == "" { + return false, errors.New("phase is WaitForUserUpload but external upload URL is empty") + } + return true, nil + } +} + +// HaveValidPhaseTransitions reports an invariant violation when +// VirtualImage.Status.Phase regresses to an earlier point of the +// provisioning lifecycle. +// +// The phases observed during provisioning are organized into ordered +// milestones: +// +// 0: "" (the controller has not yet computed a phase) +// 1: Pending +// 2: Provisioning, WaitForUserUpload +// 3: Ready +// +// Rank-2 phases are considered equivalent: Provisioning may flip to +// WaitForUserUpload (and back) while the controller waits for the user +// upload. Once a higher milestone has been observed, the phase must not +// regress to a lower one. For example, observing "" or Pending after +// Provisioning, or any of the rank-0..2 phases after Ready, is a +// violation. +// +// Phases that are not part of the provisioning happy path (Failed, +// Terminating, ImageLost) are skipped: they are handled by other +// invariants (for example [BeFailed]) and may legally follow Ready in +// unrelated lifecycle scenarios. +// +// Intended for use with [Observer.Always]. +func HaveValidPhaseTransitions() Predicate { + var ( + maxRank int + maxPhase v1alpha2.ImagePhase + hasObserved bool + ) + + return func(i *v1alpha2.VirtualImage) (bool, error) { + rank, known := imagePhaseRank(i.Status.Phase) + if !known { + return true, nil + } + + if hasObserved && rank < maxRank { + return false, fmt.Errorf( + "phase regressed from %s to %s", + displayPhase(maxPhase), displayPhase(i.Status.Phase), + ) + } + + if !hasObserved || rank > maxRank { + maxRank = rank + maxPhase = i.Status.Phase + } + hasObserved = true + return true, nil + } +} + +// imagePhaseRank returns the milestone rank of a VirtualImage phase along +// the provisioning happy path. Phases outside that path are reported as +// unknown (false) so that callers can skip them. +func imagePhaseRank(phase v1alpha2.ImagePhase) (int, bool) { + switch phase { + case "": + return 0, true + case v1alpha2.ImagePending: + return 1, true + case v1alpha2.ImageProvisioning, + v1alpha2.ImageWaitForUserUpload: + return 2, true + case v1alpha2.ImageReady: + return 3, true + default: + return 0, false + } +} + +func displayPhase(phase v1alpha2.ImagePhase) string { + if phase == "" { + return `""` + } + return fmt.Sprintf("%q", string(phase)) +} + +// ProgressExpectations describes which progress values a scenario must observe +// before the VirtualImage reaches Ready. +type ProgressExpectations struct { + RequireZero bool + RequireBetweenZeroAndFifty bool + RequireBetweenFiftyAndHundred bool + RequireIntermediateExceptFifty bool + RequireHundred bool +} + +// HaveValidProgress enforces the common VirtualImage progress contract and the +// scenario-specific coverage expectations. +func HaveValidProgress(expect ProgressExpectations, updateInterval, boundaryBudget time.Duration) Predicate { + var ( + previous *float64 + lastAdvance time.Time + observed progressObservations + ) + + return func(i *v1alpha2.VirtualImage) (bool, error) { + if i.Status.Phase == v1alpha2.ImagePending && i.Status.Progress != "" { + return false, fmt.Errorf("phase is Pending but progress is %q, expected empty progress", i.Status.Progress) + } + if i.Status.Phase == v1alpha2.ImageReady && i.Status.Progress == "" { + return false, errors.New("phase is Ready but progress is empty, expected 100%") + } + if i.Status.Progress == "" { + return true, nil + } + + current, err := parseProgress(i.Status.Progress) + if err != nil { + return false, err + } + + if current == 100 && i.Status.Phase != v1alpha2.ImageReady { + return false, fmt.Errorf("progress is 100%% but phase is %s, expected Ready", displayPhase(i.Status.Phase)) + } + if i.Status.Phase == v1alpha2.ImageReady && current != 100 { + return false, fmt.Errorf("phase is Ready but progress is %q, expected 100%%", i.Status.Progress) + } + if previous != nil && current < *previous { + return false, fmt.Errorf("progress decreased from %s to %s", formatProgressValue(*previous), formatProgressValue(current)) + } + if previous != nil && current == *previous && current == 100 && i.Status.Phase == v1alpha2.ImageReady { + return observed.satisfies(expect) + } + + now := time.Now() + if previous != nil { + budget := updateInterval + if isProgressLongPauseValue(*previous) { + budget = boundaryBudget + } + if gap := now.Sub(lastAdvance); gap > budget { + return false, fmt.Errorf( + "progress stayed at %s for %s before %s; it must grow at least every %s (0%%, 50%% and 100%% may stay up to %s)", + formatProgressValue(*previous), gap.Round(time.Second), formatProgressValue(current), updateInterval, boundaryBudget, + ) + } + if current == *previous && i.Status.Phase != v1alpha2.ImageReady { + return true, nil + } + } + + observed.record(current) + previous = ¤t + lastAdvance = now + + if i.Status.Phase != v1alpha2.ImageReady { + return true, nil + } + return observed.satisfies(expect) + } +} + +type progressObservations struct { + hasZero bool + hasBetweenZeroAndFifty bool + hasBetweenFiftyAndHundred bool + hasIntermediateExceptFifty bool + hasHundred bool +} + +func (o *progressObservations) record(p float64) { + switch { + case p == 0: + o.hasZero = true + case p > 0 && p < 50: + o.hasBetweenZeroAndFifty = true + o.hasIntermediateExceptFifty = true + case p > 50 && p < 100: + o.hasBetweenFiftyAndHundred = true + o.hasIntermediateExceptFifty = true + case p > 0 && p < 100 && p != 50: + o.hasIntermediateExceptFifty = true + case p == 100: + o.hasHundred = true + } +} + +func (o progressObservations) satisfies(expect ProgressExpectations) (bool, error) { + switch { + case expect.RequireZero && !o.hasZero: + return false, errors.New("progress reached Ready without observing 0%") + case expect.RequireBetweenZeroAndFifty && !o.hasBetweenZeroAndFifty: + return false, errors.New("progress reached Ready without observing a value in (0%;50%)") + case expect.RequireBetweenFiftyAndHundred && !o.hasBetweenFiftyAndHundred: + return false, errors.New("progress reached Ready without observing a value in (50%;100%)") + case expect.RequireIntermediateExceptFifty && !o.hasIntermediateExceptFifty: + return false, errors.New("progress reached Ready without observing a value in (0%;100%) different from 50%") + case expect.RequireHundred && !o.hasHundred: + return false, errors.New("progress reached Ready without observing 100%") + default: + return true, nil + } +} + +// isProgressLongPauseValue reports whether p may legitimately stay unchanged +// for the longer progress budget. +func isProgressLongPauseValue(p float64) bool { + return p == 0 || p == 50 || p == 100 +} + +// formatProgressValue renders a parsed progress percentage the same way the +// controller does: 0%/100% without a fraction, everything else with one decimal. +func formatProgressValue(p float64) string { + switch p { + case 0: + return "0%" + case 100: + return "100%" + default: + return fmt.Sprintf("%.1f%%", p) + } +} + +func parseProgress(progress string) (float64, error) { + value := strings.TrimSuffix(progress, "%") + if value == progress { + return 0, fmt.Errorf("progress %q does not have %% suffix", progress) + } + + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("parse progress %q: %w", progress, err) + } + if parsed < 0 || parsed > 100 { + return 0, fmt.Errorf("progress %q is outside 0..100 range", progress) + } + return parsed, nil +} + +func isConditionFresh(cond *metav1.Condition, i *v1alpha2.VirtualImage) bool { + return cond.ObservedGeneration == i.GetGeneration() +} + +func findCondition(conds []metav1.Condition, condType string) *metav1.Condition { + for i := range conds { + if conds[i].Type == condType { + return &conds[i] + } + } + return nil +} + +func condStatus(cond *metav1.Condition) metav1.ConditionStatus { + if cond == nil { + return "" + } + return cond.Status +} + +func condReason(cond *metav1.Condition) string { + if cond == nil { + return "" + } + return cond.Reason +} + +func condMessage(cond *metav1.Condition) string { + if cond == nil { + return "" + } + return cond.Message +} diff --git a/test/e2e/internal/observer/vm/observer.go b/test/e2e/internal/observer/vm/observer.go new file mode 100644 index 0000000000..93619f8c51 --- /dev/null +++ b/test/e2e/internal/observer/vm/observer.go @@ -0,0 +1,96 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package vm provides a VirtualMachine-specialized [observer.Observer] together +// with a curated set of predicates ready to be used with its Never, Always and +// WaitFor primitives. +package vm + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/observer" +) + +// Observer is a convenience type alias for the generic Observer specialized +// for VirtualMachines. +type Observer = observer.Observer[*v1alpha2.VirtualMachine] + +// Predicate is a convenience type alias for the generic Predicate specialized +// for VirtualMachines. +type Predicate = observer.Predicate[*v1alpha2.VirtualMachine] + +// StartObserver starts a VirtualMachine Observer for the given machine and +// registers a DeferCleanup that: +// +// 1. stops the underlying watch, releasing the watcher resources; +// 2. asserts that no Never/Always invariant registered on the observer was +// violated during the test. +// +// Unlike the VirtualDisk/VirtualImage observers, VirtualMachines created in the +// e2e suite use generateName, so their name is only known after creation. The +// caller is therefore expected to create the VirtualMachine first and start the +// observer afterwards; the initial watch event still carries the current state. +// +// In addition to the deferred assertion, a watcher goroutine surfaces the +// very first Never/Always violation through Ginkgo's Fail the moment it +// fires, so the test fails at the precise instant of the breach instead of +// blocking on a subsequent unrelated WaitFor and only reporting the +// violation in DeferCleanup. +func StartObserver(ctx context.Context, f *framework.Framework, vm *v1alpha2.VirtualMachine) Observer { + GinkgoHelper() + + obs, err := observer.New[*v1alpha2.VirtualMachine]( + ctx, + f.VirtClient().VirtualMachines(vm.Namespace), + vm.Name, + vm.Namespace, + ) + Expect(err).NotTo(HaveOccurred(), "failed to start observer for VirtualMachine %s/%s", vm.Namespace, vm.Name) + + go failFastOnInvariant(obs, fmt.Sprintf("VirtualMachine %s/%s", vm.Namespace, vm.Name)) + + DeferCleanup(func() { + obs.Stop() + Expect(obs.Err()).NotTo(HaveOccurred(), + "VirtualMachine %s/%s observer reported an invariant violation", + vm.Namespace, vm.Name) + }) + + return obs +} + +// failFastOnInvariant blocks until obs either reports an invariant +// violation or stops cleanly, and surfaces the first violation as a +// Ginkgo failure right away. It is meant to be launched in its own +// goroutine; defer GinkgoRecover() lets Fail's panic be captured by +// Ginkgo even though we are off the spec's main goroutine. +func failFastOnInvariant(obs Observer, label string) { + defer GinkgoRecover() + select { + case <-obs.InvariantViolated(): + case <-obs.Stopped(): + } + if err := obs.Err(); err != nil { + Fail(fmt.Sprintf("%s observer reported an invariant violation: %s", label, err)) + } +} diff --git a/test/e2e/internal/observer/vm/predicate.go b/test/e2e/internal/observer/vm/predicate.go new file mode 100644 index 0000000000..72ea04b669 --- /dev/null +++ b/test/e2e/internal/observer/vm/predicate.go @@ -0,0 +1,85 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vm + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" +) + +// BeRunning reports the VirtualMachine has reached the Running phase. Intended +// for use with [Observer.WaitFor]. +func BeRunning() Predicate { + return func(vm *v1alpha2.VirtualMachine) (bool, error) { + return vm.Status.Phase == v1alpha2.MachineRunning, nil + } +} + +// BeAgentReady reports the VirtualMachine's guest agent is ready, i.e. the +// AgentReady condition is present with Status=True. Intended for use with +// [Observer.WaitFor]. +func BeAgentReady() Predicate { + return func(vm *v1alpha2.VirtualMachine) (bool, error) { + cond := findCondition(vm.Status.Conditions, vmcondition.TypeAgentReady.String()) + if cond == nil { + return false, nil + } + return cond.Status == metav1.ConditionTrue, nil + } +} + +// BeFailed reports an invariant violation when the VirtualMachine has entered +// the terminal Degraded phase. Intended for use with [Observer.Never]. +func BeFailed() Predicate { + return func(vm *v1alpha2.VirtualMachine) (bool, error) { + if vm.Status.Phase == v1alpha2.MachineDegraded { + return true, fmt.Errorf("VirtualMachine entered Degraded phase") + } + return false, nil + } +} + +// HaveNoBootableDevice reports an invariant violation when the VirtualMachine's +// Running condition reports NoBootableDevice: the firmware scanned every block +// device and found nothing to boot from. This does not resolve on its own, so +// it is used with [Observer.Never] to fail the spec immediately instead of +// blocking until the guest-agent wait times out. +func HaveNoBootableDevice() Predicate { + return func(vm *v1alpha2.VirtualMachine) (bool, error) { + cond := findCondition(vm.Status.Conditions, vmcondition.TypeRunning.String()) + if cond == nil { + return false, nil + } + if cond.Reason == vmcondition.ReasonNoBootableDeviceFound.String() { + return true, fmt.Errorf("VirtualMachine reports no bootable device: %s", cond.Message) + } + return false, nil + } +} + +func findCondition(conds []metav1.Condition, condType string) *metav1.Condition { + for i := range conds { + if conds[i].Type == condType { + return &conds[i] + } + } + return nil +} diff --git a/test/e2e/internal/precheck/common.go b/test/e2e/internal/precheck/common.go index 536c21ccf9..22d2d9b5b9 100644 --- a/test/e2e/internal/precheck/common.go +++ b/test/e2e/internal/precheck/common.go @@ -23,6 +23,7 @@ import ( "os" "regexp" "strings" + "time" . "github.com/onsi/ginkgo/v2" @@ -319,24 +320,31 @@ func validateSpecs(specs []specInfo) error { // Run executes prechecks based on loaded spec labels. func Run(f *framework.Framework, labelFilter string) { ctx := context.Background() - // Run common prechecks first (always run) - for _, p := range commonPrechecks { - _, _ = GinkgoWriter.Write([]byte("Running common precheck: " + p.Label() + "\n")) + + // runOne executes a single precheck and prints its progress to stdout. + // stdout is used (instead of GinkgoWriter) so the progress is visible + // immediately even in parallel mode, where GinkgoWriter output is buffered. + runOne := func(kind, label string, p Precheck) { + fmt.Printf("[precheck] running %s precheck %q ...\n", kind, label) + start := time.Now() if err := p.Run(ctx, f); err != nil { - Fail("common precheck " + p.Label() + " failed: " + err.Error()) + Fail(kind + " precheck " + label + " failed: " + err.Error()) } + fmt.Printf("[precheck] %s precheck %q passed (%s)\n", kind, label, time.Since(start).Truncate(time.Millisecond)) } - // Run prechecks for loaded labels + // Run common prechecks first (always run). + for _, p := range commonPrechecks { + runOne("common", p.Label(), p) + } + + // Run prechecks for loaded labels. for _, label := range specLabels { p := registeredPrechecks[label] if p == nil { continue } - _, _ = GinkgoWriter.Write([]byte("Running precheck: " + label + "\n")) - if err := p.Run(ctx, f); err != nil { - Fail("precheck " + label + " failed: " + err.Error()) - } + runOne("labeled", label, p) } } diff --git a/test/e2e/internal/precheck/defaultstorageclass.go b/test/e2e/internal/precheck/defaultstorageclass.go index b90323ff4c..4229597501 100644 --- a/test/e2e/internal/precheck/defaultstorageclass.go +++ b/test/e2e/internal/precheck/defaultstorageclass.go @@ -30,8 +30,8 @@ const ( defaultStorageClassPrecheckEnvName = "DEFAULT_STORAGE_CLASS_PRECHECK" ) -// defaultStorageClassPrecheck implements Precheck interface for default StorageClass. -// This is a common precheck that runs for all tests. +// defaultStorageClassPrecheck verifies that tests can resolve their template StorageClass: +// STORAGE_CLASS_NAME when it is set, otherwise the cluster default StorageClass. type defaultStorageClassPrecheck struct{} func (c *defaultStorageClassPrecheck) Label() string { @@ -50,10 +50,15 @@ func (c *defaultStorageClassPrecheck) Run(ctx context.Context, f *framework.Fram return fmt.Errorf("%s=no to disable this precheck: list StorageClasses: %w", defaultStorageClassPrecheckEnvName, err) } - if config.FindDefaultStorageClass(&scList) == nil { - return fmt.Errorf("%s=no to disable this precheck: cluster has no default StorageClass. "+ - "Please set a default StorageClass with: kubectl annotate storageclass/ storageclass.kubernetes.io/is-default-class=true", - defaultStorageClassPrecheckEnvName) + templateSC, err := config.ResolveTemplateStorageClass(&scList) + if err != nil { + return fmt.Errorf("%s=no to disable this precheck: %w", defaultStorageClassPrecheckEnvName, err) + } + if templateSC == nil { + return fmt.Errorf("%s=no to disable this precheck: cluster has no default StorageClass and %s is not set. "+ + "Please set %s or set a default StorageClass with: "+ + "kubectl annotate storageclass/ storageclass.kubernetes.io/is-default-class=true", + defaultStorageClassPrecheckEnvName, config.StorageClassNameEnv, config.StorageClassNameEnv) } return nil diff --git a/test/e2e/internal/precheck/differentcsidriverstorageclass.go b/test/e2e/internal/precheck/differentcsidriverstorageclass.go new file mode 100644 index 0000000000..4d04907cfe --- /dev/null +++ b/test/e2e/internal/precheck/differentcsidriverstorageclass.go @@ -0,0 +1,91 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package precheck + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + storagev1 "k8s.io/api/storage/v1" + + "github.com/deckhouse/virtualization/test/e2e/internal/config" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" +) + +const ( + differentCSIDriverStorageClassPrecheckEnvName = "DIFFERENT_CSI_DRIVER_STORAGE_CLASS_PRECHECK" +) + +// differentCSIDriverStorageClassPrecheck implements the Precheck interface for the +// cross-CSI-driver block-device tests. It verifies that: +// 1. a WFFC StorageClass can be resolved; +// 2. the cluster has at least one StorageClass whose CSI driver (provisioner) differs +// from the WFFC one. +// +// The "different CSI driver" StorageClass is discovered automatically; no extra +// configuration is required for it. +type differentCSIDriverStorageClassPrecheck struct{} + +func (c *differentCSIDriverStorageClassPrecheck) Label() string { + return PrecheckDifferentCSIDriverStorageClass +} + +func (c *differentCSIDriverStorageClassPrecheck) Run(ctx context.Context, f *framework.Framework) error { + if !isCheckEnabled(differentCSIDriverStorageClassPrecheckEnvName) { + _, _ = GinkgoWriter.Write([]byte("different CSI driver StorageClass precheck is disabled.\n")) + return nil + } + + k8sClient := f.GenericClient() + var scList storagev1.StorageClassList + if err := k8sClient.List(ctx, &scList); err != nil { + return fmt.Errorf("%s=no to disable this precheck: list StorageClasses: %w", differentCSIDriverStorageClassPrecheckEnvName, err) + } + + wffcSC, err := config.ResolveWFFCStorageClass(&scList) + if err != nil { + return fmt.Errorf("%s=no to disable this precheck: resolve WFFC StorageClass: %w", differentCSIDriverStorageClassPrecheckEnvName, err) + } + if wffcSC == nil { + return fmt.Errorf( + "%s=no to disable this precheck: WFFC StorageClass not found. Set %s or configure a default StorageClass", + differentCSIDriverStorageClassPrecheckEnvName, config.WFFCStorageClassEnv, + ) + } + + differentSC := config.FindStorageClassWithDifferentProvisioner(&scList, wffcSC.Provisioner) + if differentSC == nil { + return fmt.Errorf( + "%s=no to disable this precheck: no StorageClass with a CSI driver different from the WFFC StorageClass %q (CSI driver %q) was found in the cluster; "+ + "the cross-CSI block-device tests require a second CSI driver to be installed", + differentCSIDriverStorageClassPrecheckEnvName, wffcSC.Name, wffcSC.Provisioner, + ) + } + + _, _ = fmt.Fprintf(GinkgoWriter, + "different CSI driver StorageClass precheck passed: the tests will use WFFC StorageClass %q (CSI driver %q) and StorageClass %q (CSI driver %q).\n", + wffcSC.Name, wffcSC.Provisioner, differentSC.Name, differentSC.Provisioner, + ) + + return nil +} + +// Register DifferentCSIDriverStorageClass precheck (not common - requires explicit label). +func init() { + RegisterPrecheck(&differentCSIDriverStorageClassPrecheck{}, false) +} diff --git a/test/e2e/internal/precheck/immediatestorageclass.go b/test/e2e/internal/precheck/immediatestorageclass.go index 369252c422..0e6475a76b 100644 --- a/test/e2e/internal/precheck/immediatestorageclass.go +++ b/test/e2e/internal/precheck/immediatestorageclass.go @@ -30,11 +30,10 @@ const ( immediateStorageClassPrecheckEnvName = "IMMEDIATE_STORAGE_CLASS_PRECHECK" ) -// immediateStorageClassPrecheck implements Precheck interface for immediate StorageClass. -// This precheck verifies that: -// 1. Default StorageClass has VolumeBindingMode=Immediate, OR -// 2. There is an immediate StorageClass with the same provisioner as default StorageClass. -// This is required for tests that work with snapshots, as PVs need to be immediately bound. +// immediateStorageClassPrecheck implements Precheck interface for the immediate StorageClass. +// This precheck verifies that an Immediate StorageClass can be resolved and uses the +// Immediate volume binding mode. This is required for tests that work with snapshots, as +// PVs need to be immediately bound. type immediateStorageClassPrecheck struct{} func (c *immediateStorageClassPrecheck) Label() string { @@ -52,20 +51,24 @@ func (c *immediateStorageClassPrecheck) Run(ctx context.Context, f *framework.Fr return fmt.Errorf("%s=no to disable this precheck: list StorageClasses: %w", immediateStorageClassPrecheckEnvName, err) } - // Find default StorageClass - defaultSC := config.FindDefaultStorageClass(&scList) - if defaultSC == nil { - return fmt.Errorf("%s=no to disable this precheck: cluster has no default StorageClass", - immediateStorageClassPrecheckEnvName) + immediateSC, err := config.ResolveImmediateStorageClass(&scList) + if err != nil { + return fmt.Errorf("%s=no to disable this precheck: %w", immediateStorageClassPrecheckEnvName, err) } - - // Check if immediate StorageClass exists with same provisioner - immediateSC := config.FindImmediateStorageClass(defaultSC, &scList) if immediateSC == nil { - return fmt.Errorf("%s=no to disable this precheck: default StorageClass %q has WaitForFirstConsumer binding mode, "+ - "and no immediate StorageClass found with the same provisioner %q. "+ - "Create an immediate StorageClass or set an immediate StorageClass as default", - immediateStorageClassPrecheckEnvName, defaultSC.Name, defaultSC.Provisioner) + return fmt.Errorf( + "%s=no to disable this precheck: immediate StorageClass not found. "+ + "Set %s explicitly or configure a default StorageClass with Immediate binding, "+ + "or with WaitForFirstConsumer binding and another Immediate StorageClass on the same CSI driver", + immediateStorageClassPrecheckEnvName, config.ImmediateStorageClassEnv, + ) + } + + if !config.IsImmediateBinding(immediateSC) { + return fmt.Errorf( + "%s=no to disable this precheck: StorageClass %q must use the Immediate volume binding mode, but it is %q", + immediateStorageClassPrecheckEnvName, immediateSC.Name, config.VolumeBindingMode(immediateSC), + ) } return nil diff --git a/test/e2e/internal/precheck/labels.go b/test/e2e/internal/precheck/labels.go index eaa9028bca..1112ba191a 100644 --- a/test/e2e/internal/precheck/labels.go +++ b/test/e2e/internal/precheck/labels.go @@ -25,16 +25,28 @@ const ( PrecheckSDN = "sdn-precheck" // PrecheckVMC - test requires VMC module to be enabled. - PrecheckVMC = "vmc-precheck" + PrecheckVMC = "vmclass-precheck" // PrecheckSVDM - test requires SVDM module to be enabled. PrecheckSVDM = "svdm-precheck" // PrecheckDefaultStorageClass - test requires default StorageClass to be configured. - PrecheckDefaultStorageClass = "defaultstorageclass-precheck" + PrecheckDefaultStorageClass = "default-sc-precheck" - // PrecheckImmediateStorageClass - test requires immediate StorageClass with same provisioner as default. - PrecheckImmediateStorageClass = "immediatestorageclass-precheck" + // PrecheckImmediateStorageClass - test requires an immediate StorageClass to be configured. + PrecheckImmediateStorageClass = "immediate-sc-precheck" + + // PrecheckWFFCStorageClass - test requires a WaitForFirstConsumer StorageClass to be + // configured. + PrecheckWFFCStorageClass = "wffc-sc-precheck" + + // PrecheckSameCSIDriverStorageClass - test requires the WFFC and immediate StorageClasses + // to be backed by the same CSI driver. + PrecheckSameCSIDriverStorageClass = "same-csi-sc-precheck" + + // PrecheckDifferentCSIDriverStorageClass - test requires a WFFC StorageClass and a + // StorageClass backed by a different CSI driver. + PrecheckDifferentCSIDriverStorageClass = "different-csi-sc-precheck" // PrecheckSnapshot - test requires snapshot-controller module to be enabled. PrecheckSnapshot = "snapshot-precheck" @@ -52,11 +64,11 @@ const ( PrecheckTargetMigration = "target-migration-precheck" // PrecheckPostCleanup - test requires postcleanup to be configured. - PrecheckPostCleanup = "postcleanup-precheck" + PrecheckPostCleanup = "post-cleanup-precheck" // PrecheckPrecreatedCVI - test requires precreated ClusterVirtualImages to be available. // This is a common precheck that runs for all tests automatically. - PrecheckPrecreatedCVI = "precreatedcvi-precheck" + PrecheckPrecreatedCVI = "precreated-cvi-precheck" // NoPrecheck - test doesn't require any prechecks. // Use this label for tests that don't depend on cluster configuration. @@ -80,6 +92,9 @@ func KnownPrecheckLabels() []string { PrecheckSVDM, PrecheckDefaultStorageClass, PrecheckImmediateStorageClass, + PrecheckWFFCStorageClass, + PrecheckSameCSIDriverStorageClass, + PrecheckDifferentCSIDriverStorageClass, PrecheckSnapshot, PrecheckVirtualization, PrecheckUSB, diff --git a/test/e2e/internal/precheck/precreatedcvi.go b/test/e2e/internal/precheck/precreatedcvi.go index da891257bd..efb9ef928b 100644 --- a/test/e2e/internal/precheck/precreatedcvi.go +++ b/test/e2e/internal/precheck/precreatedcvi.go @@ -89,6 +89,25 @@ func (p *precreatedCVIPrecheck) ensureCVIs(ctx context.Context, f *framework.Fra err := k8sClient.Get(ctx, client.ObjectKey{Name: cvi.GetName()}, existing) if err == nil { + if existing.Status.Phase == v1alpha2.ImageLost { + _, _ = fmt.Fprintf(GinkgoWriter, + "CVI %q exists but its DVCR image is lost, recreating it...\n", + cvi.GetName()) + + if err := k8sClient.Delete(ctx, existing); err != nil && !k8serrors.IsNotFound(err) { + return fmt.Errorf("failed to delete lost CVI %q: %w", cvi.GetName(), err) + } + + util.UntilObjectsDeleted(ctx, framework.ShortTimeout, existing) + + err = k8sClient.Create(ctx, cvi) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return fmt.Errorf("failed to recreate lost CVI %q: %w", cvi.GetName(), err) + } + + continue + } + // CVI already exists, verify it's ready if existing.Status.Phase != v1alpha2.ImageReady { _, _ = fmt.Fprintf(GinkgoWriter, diff --git a/test/e2e/internal/precheck/samecsidriverstorageclass.go b/test/e2e/internal/precheck/samecsidriverstorageclass.go new file mode 100644 index 0000000000..6e0bf89c56 --- /dev/null +++ b/test/e2e/internal/precheck/samecsidriverstorageclass.go @@ -0,0 +1,92 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package precheck + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + storagev1 "k8s.io/api/storage/v1" + + "github.com/deckhouse/virtualization/test/e2e/internal/config" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" +) + +const ( + sameCSIDriverStorageClassPrecheckEnvName = "SAME_CSI_DRIVER_STORAGE_CLASS_PRECHECK" +) + +// sameCSIDriverStorageClassPrecheck implements the Precheck interface for tests that clone +// objects between the WFFC and the immediate StorageClasses (e.g. a source on the immediate +// StorageClass and the produced object on the WFFC one). It verifies that both StorageClasses +// are backed by the same CSI driver (provisioner); their presence and volume binding modes are +// enforced by the dedicated WFFC and immediate StorageClass prechecks. +type sameCSIDriverStorageClassPrecheck struct{} + +func (c *sameCSIDriverStorageClassPrecheck) Label() string { + return PrecheckSameCSIDriverStorageClass +} + +func (c *sameCSIDriverStorageClassPrecheck) Run(ctx context.Context, f *framework.Framework) error { + if !isCheckEnabled(sameCSIDriverStorageClassPrecheckEnvName) { + _, _ = GinkgoWriter.Write([]byte("same CSI driver StorageClass precheck is disabled.\n")) + return nil + } + + k8sClient := f.GenericClient() + var scList storagev1.StorageClassList + if err := k8sClient.List(ctx, &scList); err != nil { + return fmt.Errorf("%s=no to disable this precheck: list StorageClasses: %w", sameCSIDriverStorageClassPrecheckEnvName, err) + } + + wffcSC, err := config.ResolveWFFCStorageClass(&scList) + if err != nil { + return fmt.Errorf("%s=no to disable this precheck: resolve WFFC StorageClass: %w", sameCSIDriverStorageClassPrecheckEnvName, err) + } + immediateSC, err := config.ResolveImmediateStorageClass(&scList) + if err != nil { + return fmt.Errorf("%s=no to disable this precheck: resolve immediate StorageClass: %w", sameCSIDriverStorageClassPrecheckEnvName, err) + } + if wffcSC == nil || immediateSC == nil { + return fmt.Errorf( + "%s=no to disable this precheck: both the WFFC StorageClass (%s) and the immediate StorageClass (%s) must be configured", + sameCSIDriverStorageClassPrecheckEnvName, config.WFFCStorageClassEnv, config.ImmediateStorageClassEnv, + ) + } + + if wffcSC.Provisioner != immediateSC.Provisioner { + return fmt.Errorf( + "%s=no to disable this precheck: WFFC StorageClass %q (CSI driver %q) and immediate StorageClass %q (CSI driver %q) "+ + "must be backed by the same CSI driver", + sameCSIDriverStorageClassPrecheckEnvName, + wffcSC.Name, wffcSC.Provisioner, immediateSC.Name, immediateSC.Provisioner, + ) + } + + _, _ = fmt.Fprintf(GinkgoWriter, + "same CSI driver StorageClass precheck passed: WFFC StorageClass %q and immediate StorageClass %q share CSI driver %q.\n", + wffcSC.Name, immediateSC.Name, wffcSC.Provisioner, + ) + + return nil +} + +// Register SameCSIDriverStorageClass precheck (not common - requires explicit label). +func init() { + RegisterPrecheck(&sameCSIDriverStorageClassPrecheck{}, false) +} diff --git a/test/e2e/internal/precheck/wffcstorageclass.go b/test/e2e/internal/precheck/wffcstorageclass.go new file mode 100644 index 0000000000..e236eaff22 --- /dev/null +++ b/test/e2e/internal/precheck/wffcstorageclass.go @@ -0,0 +1,88 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package precheck + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + storagev1 "k8s.io/api/storage/v1" + + "github.com/deckhouse/virtualization/test/e2e/internal/config" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" +) + +const ( + wffcStorageClassPrecheckEnvName = "WFFC_STORAGE_CLASS_PRECHECK" +) + +// wffcStorageClassPrecheck implements the Precheck interface for the WaitForFirstConsumer +// StorageClass used by block-device tests to provision the scenario's main resources. It +// verifies that a WFFC StorageClass can be resolved and uses the WaitForFirstConsumer +// volume binding mode. +type wffcStorageClassPrecheck struct{} + +func (c *wffcStorageClassPrecheck) Label() string { + return PrecheckWFFCStorageClass +} + +func (c *wffcStorageClassPrecheck) Run(ctx context.Context, f *framework.Framework) error { + if !isCheckEnabled(wffcStorageClassPrecheckEnvName) { + _, _ = GinkgoWriter.Write([]byte("WFFC StorageClass precheck is disabled.\n")) + return nil + } + + k8sClient := f.GenericClient() + var scList storagev1.StorageClassList + if err := k8sClient.List(ctx, &scList); err != nil { + return fmt.Errorf("%s=no to disable this precheck: list StorageClasses: %w", wffcStorageClassPrecheckEnvName, err) + } + + wffcSC, err := config.ResolveWFFCStorageClass(&scList) + if err != nil { + return fmt.Errorf("%s=no to disable this precheck: %w", wffcStorageClassPrecheckEnvName, err) + } + if wffcSC == nil { + return fmt.Errorf( + "%s=no to disable this precheck: WFFC StorageClass not found. "+ + "Set %s explicitly or configure a default StorageClass with WaitForFirstConsumer binding, "+ + "or with Immediate binding and another WaitForFirstConsumer StorageClass on the same CSI driver", + wffcStorageClassPrecheckEnvName, config.WFFCStorageClassEnv, + ) + } + + if !config.IsWFFCBinding(wffcSC) { + return fmt.Errorf( + "%s=no to disable this precheck: WFFC StorageClass %q must use the WaitForFirstConsumer "+ + "volume binding mode, but it is %q", + wffcStorageClassPrecheckEnvName, wffcSC.Name, config.VolumeBindingMode(wffcSC), + ) + } + + _, _ = fmt.Fprintf(GinkgoWriter, + "WFFC StorageClass precheck passed: the tests will use WFFC StorageClass %q (CSI driver %q).\n", + wffcSC.Name, wffcSC.Provisioner, + ) + + return nil +} + +// Register WFFCStorageClass precheck (not common - requires explicit label). +func init() { + RegisterPrecheck(&wffcStorageClassPrecheck{}, false) +} diff --git a/test/e2e/internal/rewrite/types.go b/test/e2e/internal/rewrite/types.go index 69b7da3f9d..707c6150af 100644 --- a/test/e2e/internal/rewrite/types.go +++ b/test/e2e/internal/rewrite/types.go @@ -22,14 +22,6 @@ import ( cdiv1beta1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" ) -func rewriteCDIV1beta1(resource string) schema.GroupVersionResource { - return schema.GroupVersionResource{ - Group: "cdi.internal.virtualization.deckhouse.io", - Version: "v1beta1", - Resource: resource, - } -} - func rewriteVirtualizationV1(resource string) schema.GroupVersionResource { return schema.GroupVersionResource{ Group: "internal.virtualization.deckhouse.io", @@ -47,8 +39,11 @@ type StorageProfile struct { } func (StorageProfile) GVR() schema.GroupVersionResource { - resource := rewriteInternalVirtualizationResource("storageprofiles") - return rewriteCDIV1beta1(resource) + return schema.GroupVersionResource{ + Group: "cdi.internal.virtualization.deckhouse.io", + Version: "v1beta1", + Resource: rewriteInternalVirtualizationResource("storageprofiles"), + } } type VirtualMachineInstanceMigration struct { diff --git a/test/e2e/populator/populator.go b/test/e2e/populator/populator.go new file mode 100644 index 0000000000..d84ab82dcb --- /dev/null +++ b/test/e2e/populator/populator.go @@ -0,0 +1,275 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package populator + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + k8serrors "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/rand" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/utils/ptr" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + podobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/pod" + pvcobs "github.com/deckhouse/virtualization/test/e2e/internal/observer/pvc" + "github.com/deckhouse/virtualization/test/e2e/internal/precheck" +) + +const ( + populatorPVCSize = "64Mi" + populatorDVCRPVCSize = "256Mi" + populatorWaitTimeout = 3 * time.Minute + populatorPollInterval = 2 * time.Second + snapshotStorageAPI = "snapshot.storage.k8s.io" + populatorSourcePVCName = "source" + + populationStrategyCSIClone = "csi-clone" + populationStrategySnapshot = "snapshot" + populationStrategyHostAssigned = "host-assigned" + populationStrategyDVCR = "dvcr" +) + +var _ = Describe("Populator", Label(precheck.PrecheckImmediateStorageClass, precheck.PrecheckSnapshot), func() { + var ( + f *framework.Framework + sc string + ) + + BeforeEach(func() { + f = framework.NewFramework("populator") + f.Before() + DeferCleanup(f.After) + + immediateSC := framework.GetConfig().StorageClass.ImmediateStorageClass + if immediateSC == nil { + Skip("Immediate StorageClass is not configured") + } + sc = immediateSC.Name + }) + + It("creates target PVC from PVC using CSI clone", func(ctx SpecContext) { + source := newPopulatorPVC(populatorSourcePVCName, f.Namespace().Name, sc, nil) + target := newPopulatorPVC("target-csi-clone", f.Namespace().Name, sc, map[string]string{ + annotations.AnnPVCPopulationStrategy: populationStrategyCSIClone, + annotations.AnnPVCPopulationSourcePVC: source.Name, + }) + target.Spec.DataSourceRef = &corev1.TypedObjectReference{Kind: "PersistentVolumeClaim", Name: source.Name} + + sourceObs := startPVCObserver(ctx, f, source) + targetObs := startPVCObserver(ctx, f, target) + Expect(f.CreateWithDeferredDeletion(ctx, source)).To(Succeed()) + waitPVCBound(sourceObs) + Expect(f.CreateWithDeferredDeletion(ctx, target)).To(Succeed()) + + waitPVCBoundAndDone(targetObs) + waitPopulatorCleanup(ctx, f, target.Name) + }, SpecTimeout(populatorWaitTimeout)) + + It("creates target PVC from PVC using snapshot", func(ctx SpecContext) { + source := newPopulatorPVC(populatorSourcePVCName, f.Namespace().Name, sc, nil) + snapshotName := "target-snapshot-" + rand.String(5) + target := newPopulatorPVC("target-snapshot", f.Namespace().Name, sc, map[string]string{ + annotations.AnnPVCPopulationStrategy: populationStrategySnapshot, + annotations.AnnPVCPopulationSourcePVC: source.Name, + }) + target.Spec.DataSource = &corev1.TypedLocalObjectReference{APIGroup: ptr.To(snapshotStorageAPI), Kind: "VolumeSnapshot", Name: snapshotName} + target.Spec.DataSourceRef = &corev1.TypedObjectReference{APIGroup: ptr.To(snapshotStorageAPI), Kind: "VolumeSnapshot", Name: snapshotName} + + sourceObs := startPVCObserver(ctx, f, source) + targetObs := startPVCObserver(ctx, f, target) + Expect(f.CreateWithDeferredDeletion(ctx, source)).To(Succeed()) + waitPVCBound(sourceObs) + Expect(f.CreateWithDeferredDeletion(ctx, target)).To(Succeed()) + + waitPVCBoundAndDone(targetObs) + waitPopulatorCleanup(ctx, f, target.Name) + }, SpecTimeout(populatorWaitTimeout)) + + It("creates target PVC from PVC using host assigned population", func(ctx SpecContext) { + source := newPopulatorPVC(populatorSourcePVCName, f.Namespace().Name, sc, nil) + target := newPopulatorPVC("target-host-assigned", f.Namespace().Name, sc, map[string]string{ + annotations.AnnPVCPopulationStrategy: populationStrategyHostAssigned, + annotations.AnnPVCPopulationSourcePVC: source.Name, + }) + target.Spec.DataSourceRef = &corev1.TypedObjectReference{ + APIGroup: ptr.To("virtualization.deckhouse.io"), + Kind: "PersistentVolumeClaim", + Name: source.Name, + } + + sourceObs := startPVCObserver(ctx, f, source) + targetObs := startPVCObserver(ctx, f, target) + Expect(f.CreateWithDeferredDeletion(ctx, source)).To(Succeed()) + waitPVCBound(sourceObs) + writeRawDiskImage(ctx, f, source.Name) + Expect(f.CreateWithDeferredDeletion(ctx, target)).To(Succeed()) + + waitPVCBoundAndDone(targetObs) + waitPopulatorCleanup(ctx, f, target.Name) + }, SpecTimeout(populatorWaitTimeout)) + + It("creates target PVC from DVCR", func(ctx SpecContext) { + cvi := &v1alpha2.ClusterVirtualImage{} + Expect(f.GenericClient().Get(ctx, crclient.ObjectKey{Name: object.PrecreatedCVIAlpineBIOS}, cvi)).To(Succeed()) + Expect(cvi.Status.Target.RegistryURL).NotTo(BeEmpty()) + + target := newPopulatorPVCWithSize("target-dvcr", f.Namespace().Name, sc, populatorDVCRPVCSize, map[string]string{ + annotations.AnnPVCPopulationStrategy: populationStrategyDVCR, + annotations.AnnPVCPopulationSourceDVCR: "docker://" + cvi.Status.Target.RegistryURL, + }) + target.Spec.DataSourceRef = &corev1.TypedObjectReference{ + APIGroup: ptr.To("virtualization.deckhouse.io"), + Kind: "ClusterVirtualImage", + Name: cvi.Name, + } + targetObs := startPVCObserver(ctx, f, target) + Expect(f.CreateWithDeferredDeletion(ctx, target)).To(Succeed()) + + waitPVCBoundAndDone(targetObs) + waitPopulatorCleanup(ctx, f, target.Name) + }, SpecTimeout(populatorWaitTimeout)) +}) + +func newPopulatorPVC(name, namespace, storageClass string, anns map[string]string) *corev1.PersistentVolumeClaim { + return newPopulatorPVCWithSize(name, namespace, storageClass, populatorPVCSize, anns) +} + +func newPopulatorPVCWithSize(name, namespace, storageClass, size string, anns map[string]string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + TypeMeta: metav1.TypeMeta{Kind: "PersistentVolumeClaim", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: anns, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: ptr.To(storageClass), + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + VolumeMode: ptr.To(corev1.PersistentVolumeFilesystem), + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse(size), + }}, + }, + } +} + +func startPVCObserver(ctx context.Context, f *framework.Framework, pvc *corev1.PersistentVolumeClaim) pvcobs.Observer { + GinkgoHelper() + obs := pvcobs.StartObserver(ctx, f, pvc) + obs.Never(pvcobs.BeLost()) + return obs +} + +func waitPVCBoundAndDone(obs pvcobs.Observer) { + GinkgoHelper() + err := obs.WaitFor(pvcobs.BeBoundAndPopulated(), populatorWaitTimeout) + Expect(err).NotTo(HaveOccurred()) +} + +func waitPVCBound(obs pvcobs.Observer) { + GinkgoHelper() + err := obs.WaitFor(pvcobs.BeBound(), populatorWaitTimeout) + Expect(err).NotTo(HaveOccurred()) +} + +func waitPopulatorCleanup(ctx context.Context, f *framework.Framework, targetName string) { + GinkgoHelper() + target := &corev1.PersistentVolumeClaim{} + Expect(f.GenericClient().Get(ctx, crclient.ObjectKey{Name: targetName, Namespace: f.Namespace().Name}, target)).To(Succeed()) + podNames := []string{ + "d8v-pvc-pvc-importer-" + string(target.UID), + "d8v-pvc-pvc-source-importer-" + string(target.UID), + "d8v-pvc-pvc-target-importer-" + string(target.UID), + } + + err := wait.PollUntilContextTimeout(ctx, populatorPollInterval, populatorWaitTimeout, true, func(ctx context.Context) (bool, error) { + for _, key := range []types.NamespacedName{ + {Name: targetName + "-prime", Namespace: f.Namespace().Name}, + {Name: targetName + "-prime-scratch", Namespace: f.Namespace().Name}, + } { + pvc := &corev1.PersistentVolumeClaim{} + err := f.GenericClient().Get(ctx, key, pvc) + if err == nil { + return false, nil + } + if !k8serrors.IsNotFound(err) { + return false, err + } + } + for _, podName := range podNames { + pod := &corev1.Pod{} + err := f.GenericClient().Get(ctx, crclient.ObjectKey{Name: podName, Namespace: f.Namespace().Name}, pod) + if err == nil { + return false, nil + } + if !k8serrors.IsNotFound(err) { + return false, err + } + } + return true, nil + }) + Expect(err).NotTo(HaveOccurred()) +} + +func writeRawDiskImage(ctx context.Context, f *framework.Framework, sourcePVC string) { + GinkgoHelper() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "write-source-disk", + Namespace: f.Namespace().Name, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + SecurityContext: &corev1.PodSecurityContext{ + FSGroup: ptr.To[int64](65532), + }, + Containers: []corev1.Container{{ + Name: "writer", + Image: framework.GetConfig().HelperImages.CurlImage, + Command: []string{"/bin/sh", "-c"}, + Args: []string{"dd if=/dev/zero of=/data/disk.img bs=1M count=1"}, + VolumeMounts: []corev1.VolumeMount{{ + Name: "data", + MountPath: "/data", + }}, + }}, + Volumes: []corev1.Volume{{ + Name: "data", + VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: sourcePVC, + }}, + }}, + }, + } + obs := podobs.StartObserver(ctx, f, pod) + obs.Never(podobs.BeFailed()) + Expect(f.CreateWithDeferredDeletion(ctx, pod)).To(Succeed()) + err := obs.WaitFor(podobs.BeSucceeded(), populatorWaitTimeout) + Expect(err).NotTo(HaveOccurred()) +} diff --git a/test/e2e/vm/no_bootable_device.go b/test/e2e/vm/no_bootable_device.go index b35403bce4..7222585f67 100644 --- a/test/e2e/vm/no_bootable_device.go +++ b/test/e2e/vm/no_bootable_device.go @@ -26,6 +26,7 @@ import ( "k8s.io/utils/ptr" crclient "sigs.k8s.io/controller-runtime/pkg/client" + vibuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vi" vmbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vm" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -78,4 +79,45 @@ var _ = Describe("VirtualMachineNoBootableDevice", Label(precheck.NoPrecheck), f g.Expect(runningCondition.Status).To(Equal(metav1.ConditionTrue)) }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) }) + + It("sets Running condition reason to NoBootableDevice when booting from a non-bootable qcow2 image", func() { + By("Creating a VirtualImage from a non-bootable qcow2 image") + qcowVI := vibuilder.New( + vibuilder.WithName("vi-qcow2"), + vibuilder.WithNamespace(f.Namespace().Name), + vibuilder.WithStorage(v1alpha2.StorageContainerRegistry), + vibuilder.WithDataSourceHTTP(object.ImageTestDataQCOW, nil, nil), + ) + + err := f.CreateWithDeferredDeletion(ctx, qcowVI) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for the VirtualImage to be ready") + util.UntilObjectPhase(ctx, string(v1alpha2.ImageReady), framework.LongTimeout, qcowVI) + + By("Creating a virtual machine booting from the qcow2 image") + vm := object.NewMinimalVM("vm-qcow2-", f.Namespace().Name, + vmbuilder.WithBlockDeviceRefs(v1alpha2.BlockDeviceSpecRef{ + Kind: v1alpha2.ImageDevice, + Name: qcowVI.Name, + }), + ) + + err = f.CreateWithDeferredDeletion(ctx, vm) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for virtual machine to be Running") + util.UntilObjectPhase(ctx, string(v1alpha2.MachineRunning), framework.LongTimeout, vm) + + By("Checking Running condition reason indicates no bootable device") + Eventually(func(g Gomega) { + err = f.GenericClient().Get(ctx, crclient.ObjectKeyFromObject(vm), vm) + g.Expect(err).NotTo(HaveOccurred()) + + runningCondition, found := conditions.GetCondition(vmcondition.TypeRunning, vm.Status.Conditions) + g.Expect(found).To(BeTrue()) + g.Expect(runningCondition.Reason).To(Equal(vmcondition.ReasonNoBootableDeviceFound.String())) + g.Expect(runningCondition.Status).To(Equal(metav1.ConditionTrue)) + }).WithTimeout(framework.LongTimeout).WithPolling(framework.PollingInterval).Should(Succeed()) + }) }) diff --git a/tools/kubeconform/fixtures/module-values.yaml b/tools/kubeconform/fixtures/module-values.yaml index ea315b1d3f..fbc5d5ec02 100644 --- a/tools/kubeconform/fixtures/module-values.yaml +++ b/tools/kubeconform/fixtures/module-values.yaml @@ -100,8 +100,7 @@ global: - deckhouse.io/v1alpha1/ClusterAuthorizationRule - deckhouse.io/v1alpha1/DexProvider - deckhouse.io/v1beta1/DeploymentMetric - - cdi.internal.virtualization.deckhouse.io/v1beta1/InternalVirtualizationCDI - - cdi.internal.virtualization.deckhouse.io/v1beta1/InternalVirtualizationDataVolume + - cdi.internal.virtualization.deckhouse.io/v1beta1/InternalVirtualizationStorageProfile - templates.gatekeeper.sh/v1alpha1/ConstraintTemplate - cilium.io/v2alpha1/CiliumNodeConfig - deckhouse.io/v1alpha1/LocalPathProvisioner @@ -111,7 +110,6 @@ global: - deckhouse.io/v1alpha1/Instance - deckhouse.io/v1/DexProvider - deckhouse.io/v1alpha1/GrafanaDashboardDefinition - - virtualization.deckhouse.io/v1alpha2/VirtualMachineRestore - status.gatekeeper.sh/v1beta1/ExpansionTemplatePodStatus - cilium.io/v2alpha1/CiliumL2AnnouncementPolicy - snapshot.storage.k8s.io/v1/VolumeSnapshotClass @@ -333,6 +331,7 @@ global: vmRouteForge: sha256:0000000000000000000000000000000000000000000000000000000000000000 virtualizationDraUsb: sha256:0000000000000000000000000000000000000000000000000000000000000000 usbModules: sha256:0000000000000000000000000000000000000000000000000000000000000000 + virtualDiskImporter: sha256:0000000000000000000000000000000000000000000000000000000000000000 registry: CA: "" address: some-registry.io