diff --git a/harvester_robot_tests/keywords/common.resource b/harvester_robot_tests/keywords/common.resource index 3a587f20c..290333ede 100644 --- a/harvester_robot_tests/keywords/common.resource +++ b/harvester_robot_tests/keywords/common.resource @@ -11,14 +11,31 @@ Set up test environment common_keywords.init harvester api client ${HARVESTER_ENDPOINT} ${HARVESTER_USERNAME} ${HARVESTER_PASSWORD} common_keywords.init k8s api client +Generate Unique Name + [Arguments] ${prefix1}=${EMPTY} ${prefix2}=${EMPTY} + [Documentation] Generate a unique name with optional prefixes and yymmdd-HHMMSS-ffffqa timestamp suffix + ... Generate Unique Name -> '260603-145834-1273qa' + ... Generate Unique Name foo -> 'foo-260603-145834-1273qa' + ... Generate Unique Name foo bar -> 'foo-bar-2260603-145834-1273qa' + ${name}= common_keywords.generate_name_with_suffix ${prefix1} ${prefix2} + RETURN ${name} + Cleanup test resources [Documentation] Clean up all resources created during tests Log Cleaning up test resources - Run Keyword And Ignore Error common_keywords.Cleanup VMs - Run Keyword And Ignore Error common_keywords.Cleanup volumes - Run Keyword And Ignore Error common_keywords.Cleanup images - Run Keyword And Ignore Error common_keywords.Cleanup networks - Run Keyword And Ignore Error common_keywords.Cleanup backups + Run Keyword And Ignore Error common_keywords.cleanup_vms + Run Keyword And Ignore Error common_keywords.cleanup_volumes + Run Keyword And Ignore Error common_keywords.cleanup_images + Run Keyword And Ignore Error common_keywords.cleanup_storageclasses + Run Keyword And Ignore Error common_keywords.cleanup_networks + Run Keyword And Ignore Error common_keywords.cleanup_backups + +Common Suite Teardown + Run Keyword If All Tests Passed Cleanup test resources + Run Keyword If Any Tests Failed Log Variables + +Common Test Teardown + Run Keyword If Test Failed Log Variables List Pods By Label [Arguments] ${namespace} ${label_selector} ${status}=${None} diff --git a/harvester_robot_tests/keywords/host.resource b/harvester_robot_tests/keywords/host.resource index 9fa1c5edb..3aa3c376b 100644 --- a/harvester_robot_tests/keywords/host.resource +++ b/harvester_robot_tests/keywords/host.resource @@ -134,3 +134,11 @@ Count Standard Nodes ${node_names}= host_keywords.get_standard_nodes ${node_count}= Get Length ${node_names} RETURN ${node_count} + +Tag Storages + [Arguments] ${tag} ${disk_by_node} + FOR ${node} ${disk} IN &{disk_by_node} + host_keywords.add_lh_node_disk_tag ${node} ${disk} ${tag} + Wait Until Keyword Succeeds ${WAIT_TIMEOUT} ${RETRY_INTERVAL} + ... host_keywords.is_lh_node_disk_tag_present ${node} ${disk} ${tag} + END \ No newline at end of file diff --git a/harvester_robot_tests/keywords/image.resource b/harvester_robot_tests/keywords/image.resource index d4ba2e9fc..21c3bd7fa 100644 --- a/harvester_robot_tests/keywords/image.resource +++ b/harvester_robot_tests/keywords/image.resource @@ -6,9 +6,9 @@ Library ../libs/keywords/image_keywords.py *** Keywords *** Create image from url with name - [Arguments] ${image_name} ${image_url} ${checksum}= - [Documentation] Create image from URL using provided name - image_keywords.create image from url ${image_name} ${image_url} checksum=${checksum} + [Arguments] ${image_name} ${image_url} ${storage_class}=${EMPTY} ${checksum}= + [Documentation] Create image from URL using provided name and optional storage class + image_keywords.create image from url ${image_name} ${image_url} storage_class=${storage_class} checksum=${checksum} Wait for image downloaded by name [Arguments] ${image_name} ${timeout}=${WAIT_TIMEOUT} @@ -25,3 +25,9 @@ Get image status by name [Documentation] Get image status ${status}= image_keywords.get image status ${image_name} ${namespace} RETURN ${status} + +Image is available for VM creation + [Arguments] ${image_name} ${image_url} + [Documentation] Create an image from URL (delegates to Image module). + Create image from url with name ${image_name} ${image_url} + Wait for image downloaded by name ${image_name} \ No newline at end of file diff --git a/harvester_robot_tests/keywords/storage.resource b/harvester_robot_tests/keywords/storage.resource index e3c6a2460..87465f9a0 100644 --- a/harvester_robot_tests/keywords/storage.resource +++ b/harvester_robot_tests/keywords/storage.resource @@ -6,11 +6,6 @@ Resource common.resource Resource host.resource -*** Variables *** -&{DATA_DISK_BY_NODE} &{EMPTY} -${LHv2_SC_REPLICAS} ${EMPTY} - - *** Keywords *** # instance-manager (IM) Standard Nodes Have Running v2 IM Pods @@ -22,38 +17,31 @@ Standard Nodes Have Running v2 IM Pods Should Be Equal As Integers ${running_v2_im_pods_count} ${standard_nodes_count} Standard Nodes Have No v2 IM Pods - ${standard_nodes_count}= host.Count Standard Nodes ${v2_im_pods_count}= common.Count Pods By Label ... ${LONGHORN_NAMESPACE} ... longhorn.io/data-engine=v2,longhorn.io/component=instance-manager Should Be Equal As Integers ${v2_im_pods_count} 0 # Provision LHv2 Storages -Pick Unprovisioned Data Disks - [Arguments] ${namespace}=${LONGHORN_NAMESPACE} - [Documentation] Pick usable data disks per standard node as disk name by node name - &{disk_by_node}= storage_keywords.pick_unprovisioned_data_disks ${namespace} - RETURN &{disk_by_node} - -Cluster Has Available Data Disks And Set Suite Variables +Get Available NVMe Disks [Documentation] Check if there are 1 to 3 unprovisioned data disks - &{DATA_DISK_BY_NODE}= Pick Unprovisioned Data Disks - &{DATA_DISK_BY_NODE}= Evaluate dict(list($DATA_DISK_BY_NODE.items())[:3]) - ${data_disks_count}= Get Length ${DATA_DISK_BY_NODE} - Should Be True ${data_disks_count} >= 1 Cluster has NO available data disk + &{node_nvme_map}= storage_keywords.pick_unprovisioned_nvme_disks ${LONGHORN_NAMESPACE} + &{node_nvme_map}= Evaluate dict(list($node_nvme_map.items())[:3]) + ${nvme_count}= Get Length ${node_nvme_map} + Should Be True ${nvme_count} >= 1 Cluster has NO available data disk + RETURN &{node_nvme_map} - Set Suite Variable &{DATA_DISK_BY_NODE} - Set Suite Metadata DATA_DISK_BY_NODE ${DATA_DISK_BY_NODE} - Set Suite Variable ${LHv2_SC_REPLICAS} ${data_disks_count} - Set Suite Metadata LHv2_SC_REPLICAS ${LHv2_SC_REPLICAS} - -Provision Storages With LHv2 Data Engine - [Arguments] &{disk_by_node} +Provision Storages + [Arguments] ${engine} ${disk_by_node} FOR ${node} ${disk} IN &{disk_by_node} Set Test Message \nProvision blockdevice ${LONGHORN_NAMESPACE}/${disk} with LHv2 append=yes - storage_keywords.provision_longhorn_storage ${disk} LonghornV2 ${LONGHORN_NAMESPACE} + storage_keywords.provision_longhorn_storage ${disk} ${engine} ${LONGHORN_NAMESPACE} END +Provision Storages With LHv2 Data Engine + [Arguments] ${disk_by_node} + Provision Storages LonghornV2 ${disk_by_node} + Blockdevice Is Provisioned [Arguments] ${name} ${namespace}=${LONGHORN_NAMESPACE} ${is_provisioned}= storage_keywords.is_blockdevice_provisioned ${name} ${namespace} @@ -65,8 +53,8 @@ Longhorn Node Disk Is ${condition} ${cond_status}= storage_keywords.get_lh_node_disk_status_condition ${node_name} ${disk_name} ${condition} Should Be True ${cond_status} -Wait Until LHv2 Storages Are Provisioned - [Arguments] &{disk_by_node} +Wait Until Storages Are Provisioned + [Arguments] ${disk_by_node} [Documentation] Check provisioned storages are ``Ready``, ``Schedulable`` and ``Provisioned`` FOR ${node} ${disk} IN &{disk_by_node} Log Waiting storage ${disk} on node ${node} become ``Ready``, ``Schedulable`` and ``Provisioned``... diff --git a/harvester_robot_tests/keywords/storageclass.resource b/harvester_robot_tests/keywords/storageclass.resource new file mode 100644 index 000000000..6bfb2c52b --- /dev/null +++ b/harvester_robot_tests/keywords/storageclass.resource @@ -0,0 +1,27 @@ +*** Settings *** +Documentation StorageClass Keywords +Library ../libs/keywords/storage_keywords.py +Resource variables.resource + + +*** Keywords *** +Create Storage Class + [Arguments] ${name} ${data_engine} ${number_of_replicas} ${disk_selector} + [Documentation] Create a StorageClass with Longhorn provisioner. + ... disk_selector can be a comma-separated list (e.g. lhv2,fast). + storage_keywords.create_storageclass ${name} ${data_engine} ${number_of_replicas} ${disk_selector} + Wait Until Keyword Succeeds ${WAIT_TIMEOUT} ${RETRY_INTERVAL} + ... Storage Class Is Present ${name} + +Create LHv2 Storage Class + [Arguments] ${name} ${number_of_replicas} ${disk_selector} + Create Storage Class ${name} v2 ${number_of_replicas} ${disk_selector} + +Delete Storage Class + [Arguments] ${name} + storage_keywords.delete_storageclass ${name} + +Storage Class Is Present + [Arguments] ${name} + ${sc}= storage_keywords.get_storageclass ${name} + Should Not Be Empty ${sc} \ No newline at end of file diff --git a/harvester_robot_tests/keywords/variables.resource b/harvester_robot_tests/keywords/variables.resource index 71166ab96..39c01c00b 100644 --- a/harvester_robot_tests/keywords/variables.resource +++ b/harvester_robot_tests/keywords/variables.resource @@ -11,7 +11,7 @@ ${HARVESTER_USERNAME} %{HARVESTER_USERNAME=admin} ${HARVESTER_PASSWORD} %{HARVESTER_PASSWORD=password} # Timeouts and Intervals -${WAIT_TIMEOUT} 600 +${WAIT_TIMEOUT} %{WAIT_TIMEOUT=600} ${RETRY_INTERVAL} 3 ${SLEEP_TIMEOUT} 3 diff --git a/harvester_robot_tests/keywords/virtualmachine.resource b/harvester_robot_tests/keywords/virtualmachine.resource index b79b009d5..0ca84f1bf 100644 --- a/harvester_robot_tests/keywords/virtualmachine.resource +++ b/harvester_robot_tests/keywords/virtualmachine.resource @@ -1,8 +1,6 @@ *** Settings *** Documentation VM Keywords Resource variables.resource -Resource common.resource -Resource image.resource Library ../libs/keywords/vm_keywords.py *** Keywords *** @@ -13,9 +11,9 @@ List VMs by prefix RETURN ${vms} VM is created - [Arguments] ${vm_name} &{config} + [Arguments] ${vm_name} ${image_id} ${cpu}=2 ${memory}=4Gi &{config} [Documentation] Create a virtual machine with provided name - vm_keywords.create vm ${vm_name} &{config} + vm_keywords.create vm ${vm_name} ${image_id} ${cpu} ${memory} &{config} VM is deleted [Arguments] ${vm_name} @@ -48,7 +46,7 @@ VM should be deleted vm_keywords.wait for vm deleted ${vm_name} VM should have IP addresses - [Arguments] ${vm_name} ${networks} + [Arguments] ${vm_name} ${networks}=${None} [Documentation] Wait for VM to obtain IP addresses vm_keywords.wait for vm ip addresses ${vm_name} ${networks} @@ -64,8 +62,7 @@ Get VM data checksum ${checksum}= vm_keywords.get vm data checksum ${vm_name} RETURN ${checksum} -Image is available for VM creation - [Arguments] ${image_name} ${image_url} - [Documentation] Create an image from URL (delegates to Image module). - image.Create image from url with name ${image_name} ${image_url} - image.Wait for image downloaded by name ${image_name} +Update Volume Size to ${new_size}Gi From VM + [Arguments] ${vm_name} ${disk_name} + [Documentation] Update the size of a VM disk + vm_keywords.update_vm_disk_size ${vm_name} ${disk_name} ${new_size}Gi diff --git a/harvester_robot_tests/keywords/volume.resource b/harvester_robot_tests/keywords/volume.resource new file mode 100644 index 000000000..ac280632d --- /dev/null +++ b/harvester_robot_tests/keywords/volume.resource @@ -0,0 +1,39 @@ +*** Settings *** +Documentation Volume Keywords +Resource variables.resource +Library ../libs/keywords/volume_keywords.py + +*** Keywords *** +# size +Get Volume Size + [Arguments] ${vol_name} + ${status}= volume_keywords.get_volume_status ${vol_name} + RETURN ${status["capacity"]["storage"]} + +Volume Size Is ${size_gi}Gi + [Arguments] ${vol_name} + ${actual_size}= Get Volume Size ${vol_name} + Should Be Equal As Strings ${actual_size} ${size_gi}Gi + +Update Volume Size to ${new_size}Gi + [Arguments] ${vol_name} + volume_keywords.expand_volume ${vol_name} ${new_size}Gi + +# status +Volume Phase Is ${phase} + [Arguments] ${vol_name} + ${status}= volume_keywords.get_volume_status ${vol_name} + Should Be Equal As Strings ${status["phase"]} ${phase} + +Volume State Is Ready/In-use + [Arguments] ${vol_name} + ${status}= volume_keywords.get_volume_status ${vol_name} + Should Be Empty ${status["conditions"]} + +Wait Until Volume Is Active + [Arguments] ${vol_name} + Sleep ${SLEEP_TIMEOUT}s reason=Buffer time for volume changes + Wait Until Keyword Succeeds ${WAIT_TIMEOUT} ${RETRY_INTERVAL} + ... Volume Phase Is Bound ${vol_name} + Wait Until Keyword Succeeds ${WAIT_TIMEOUT} ${RETRY_INTERVAL} + ... Volume State Is Ready/In-use ${vol_name} diff --git a/harvester_robot_tests/libs/blockdevice/base.py b/harvester_robot_tests/libs/blockdevice/base.py index d1c6722af..bd3ea277d 100644 --- a/harvester_robot_tests/libs/blockdevice/base.py +++ b/harvester_robot_tests/libs/blockdevice/base.py @@ -7,26 +7,17 @@ class Base(ABC): + def __init__(self): + self.unsupported_msg = f"Unsupported by {self.__class__.__name__}, falling back." + @abstractmethod def list(self, namespace): - """List blockdevices, optionally by namespace - - Return a list of blockdevices - """ - pass + raise NotImplementedError(self.unsupported_msg) @abstractmethod def get(self, name, namespace): - """Get blockdevice, optionally by name and namespace - - Return a blockdevice or None - """ - pass + raise NotImplementedError(self.unsupported_msg) @abstractmethod def provision_longhorn_storage(self, name, engine_version, namespace): - """Provision a blockdevice for Longhorn storage - - Return nothing, but raise exception if operation fails - """ - pass + raise NotImplementedError(self.unsupported_msg) diff --git a/harvester_robot_tests/libs/blockdevice/blockdevice.py b/harvester_robot_tests/libs/blockdevice/blockdevice.py index 3ca44954e..c0c1c57f4 100644 --- a/harvester_robot_tests/libs/blockdevice/blockdevice.py +++ b/harvester_robot_tests/libs/blockdevice/blockdevice.py @@ -3,8 +3,7 @@ Layer 4: Component and its implementation """ -import os -from constant import HarvesterOperationStrategy +from utility.utility import logging from .base import Base from .crd import CRD from .rest import Rest @@ -13,25 +12,26 @@ class Blockdevice(Base): def __init__(self): """Initialize Blockdevice component""" - try: - strategy_str = os.getenv("HARVESTER_OPERATION_STRATEGY", "crd").lower() - self._strategy = HarvesterOperationStrategy(strategy_str) - except ValueError: - self._strategy = HarvesterOperationStrategy.CRD - - match self._strategy: - case HarvesterOperationStrategy.CRD: - self.blockdevice = CRD() - case HarvesterOperationStrategy.REST: - self.blockdevice = Rest() + self.crd = CRD() + self.rest = Rest() def list(self, namespace): - blockdevices = self.blockdevice.list(namespace) - return blockdevices + try: + return self.crd.list(namespace) + except NotImplementedError as e: + logging(e) + return self.rest.list(namespace) def get(self, name, namespace): - blockdevice = self.blockdevice.get(name, namespace) - return blockdevice + try: + return self.crd.get(name, namespace) + except NotImplementedError as e: + logging(e) + return self.rest.get(name, namespace) def provision_longhorn_storage(self, name, engine_version, namespace): - self.blockdevice.provision_longhorn_storage(name, engine_version, namespace) + try: + self.crd.provision_longhorn_storage(name, engine_version, namespace) + except NotImplementedError as e: + logging(e) + self.rest.provision_longhorn_storage(name, engine_version, namespace) diff --git a/harvester_robot_tests/libs/blockdevice/crd.py b/harvester_robot_tests/libs/blockdevice/crd.py index 47bed2bee..a0902a0b6 100644 --- a/harvester_robot_tests/libs/blockdevice/crd.py +++ b/harvester_robot_tests/libs/blockdevice/crd.py @@ -16,6 +16,7 @@ class CRD(Base): def __init__(self): """Initialize Kubernetes client""" + super().__init__() self.core_api = client.CoreV1Api() self.custom_api = client.CustomObjectsApi() self.common_parameters = { diff --git a/harvester_robot_tests/libs/blockdevice/rest.py b/harvester_robot_tests/libs/blockdevice/rest.py index 9a51efae6..f6bf84031 100644 --- a/harvester_robot_tests/libs/blockdevice/rest.py +++ b/harvester_robot_tests/libs/blockdevice/rest.py @@ -3,13 +3,19 @@ Layer 4: Component and its implementation """ -from utility.utility import get_harvester_api_client from .base import Base class Rest(Base): """REST implementation for Blockdevice operations using Harvester API""" - def __init__(self): - self.api_client = get_harvester_api_client() - self.port_forward_process = None + super().__init__() + + def list(self, namespace): + return super().list(namespace) + + def get(self, name, namespace): + return super().get(name, namespace) + + def provision_longhorn_storage(self, name, engine_version, namespace): + return super().provision_longhorn_storage(name, engine_version, namespace) diff --git a/harvester_robot_tests/libs/host/base.py b/harvester_robot_tests/libs/host/base.py index 2a0e6f166..f7e0bf217 100644 --- a/harvester_robot_tests/libs/host/base.py +++ b/harvester_robot_tests/libs/host/base.py @@ -5,7 +5,8 @@ class Base(ABC): - """Base class for Host implementations""" + def __init__(self): + self.unsupported_msg = f"Unsupported by {self.__class__.__name__}, falling back." @abstractmethod def list_nodes(self): @@ -76,3 +77,13 @@ def add_node_label(self, node_name, key, value): def remove_node_label(self, node_name, key): """Remove a label from a node""" pass + + @abstractmethod + def add_lh_node_disk_tag(self, node_name, disk_name, tag): + """Add a tag to a Longhorn node disk""" + raise NotImplementedError(self.unsupported_msg) + + @abstractmethod + def remove_lh_node_disk_tag(self, node_name, disk_name, tag): + """Remove a tag from a Longhorn node disk""" + raise NotImplementedError(self.unsupported_msg) diff --git a/harvester_robot_tests/libs/host/crd.py b/harvester_robot_tests/libs/host/crd.py index 5f114bc42..cae99e84f 100644 --- a/harvester_robot_tests/libs/host/crd.py +++ b/harvester_robot_tests/libs/host/crd.py @@ -5,7 +5,7 @@ import time from kubernetes import client from kubernetes.client.rest import ApiException -from crd import get_cr +from crd import get_cr, patch_cr from utility.utility import logging, get_retry_count_and_interval from constant import ( DEFAULT_TIMEOUT_SHORT, DEFAULT_TIMEOUT, @@ -410,3 +410,79 @@ def get_lh_node(self, node_name): logging(f"Can not find LH node {LONGHORN_NAMESPACE}/{node_name}", level='WARNING') return None raise Exception(f"Fail to get LH node {LONGHORN_NAMESPACE}/{node_name}: {e}") + + def add_lh_node_disk_tag(self, node_name, disk_name, tag): + lh_node = self.get_lh_node(node_name) + if not lh_node: + raise Exception(f"Longhorn node {LONGHORN_NAMESPACE}/{node_name} not found") + + disks = lh_node.get("spec", {}).get("disks", {}) + if disk_name not in disks: + raise Exception( + f"Disk {disk_name} not found on Longhorn node {LONGHORN_NAMESPACE}/{node_name}" + ) + + tags = disks.get(disk_name, {}).get("tags", []) + if tag in tags: + return lh_node + + patch_body = { + "spec": { + "disks": { + disk_name: { + "tags": tags + [tag] + } + } + } + } + + try: + patch_cr( + **self.lh_nodes_parameters, + name=node_name, + body=patch_body + ) + except ApiException as e: + raise Exception( + f"Failed to tag {tag} on {LONGHORN_NAMESPACE}/{node_name} disk {disk_name}: {e}" + ) + + return self.get_lh_node(node_name) + + def remove_lh_node_disk_tag(self, node_name, disk_name, tag): + lh_node = self.get_lh_node(node_name) + if not lh_node: + raise Exception(f"Longhorn node {LONGHORN_NAMESPACE}/{node_name} not found") + + disks = lh_node.get("spec", {}).get("disks", {}) + if disk_name not in disks: + raise Exception( + f"Disk {disk_name} not found on Longhorn node {LONGHORN_NAMESPACE}/{node_name}" + ) + + tags = disks.get(disk_name, {}).get("tags", []) + if tag not in tags: + return lh_node + + patch_body = { + "spec": { + "disks": { + disk_name: { + "tags": [existing for existing in tags if existing != tag] + } + } + } + } + + try: + patch_cr( + **self.lh_nodes_parameters, + name=node_name, + body=patch_body + ) + except ApiException as e: + raise Exception( + f"Failed to untag {tag} on {LONGHORN_NAMESPACE}/{node_name} disk {disk_name}: {e}" + ) + + return self.get_lh_node(node_name) diff --git a/harvester_robot_tests/libs/host/host.py b/harvester_robot_tests/libs/host/host.py index 7f630df47..3dd6767a4 100644 --- a/harvester_robot_tests/libs/host/host.py +++ b/harvester_robot_tests/libs/host/host.py @@ -81,3 +81,9 @@ def cleanup(self): # Longhorn Node def get_lh_node(self, node_name): return self.host.get_lh_node(node_name) + + def add_lh_node_disk_tag(self, node_name, disk_name, tag): + self.host.add_lh_node_disk_tag(node_name, disk_name, tag) + + def remove_lh_node_disk_tag(self, node_name, disk_name, tag): + self.host.remove_lh_node_disk_tag(node_name, disk_name, tag) diff --git a/harvester_robot_tests/libs/host/rest.py b/harvester_robot_tests/libs/host/rest.py index a8b25074f..70962dea6 100644 --- a/harvester_robot_tests/libs/host/rest.py +++ b/harvester_robot_tests/libs/host/rest.py @@ -9,10 +9,8 @@ class Rest(Base): - """Host Rest implementation - makes actual API calls""" - def __init__(self): - pass + super().__init__() def list_nodes(self): """List all nodes""" @@ -172,6 +170,12 @@ def get_node_vms(self, node_name): return node_vms + def add_lh_node_disk_tag(self, node_name, disk_name, tag): + return super().add_lh_node_disk_tag(node_name, disk_name, tag) + + def remove_lh_node_disk_tag(self, node_name, disk_name, tag): + return super().remove_lh_node_disk_tag(node_name, disk_name, tag) + def cleanup(self): """Clean up host test artifacts""" logging('Cleaning up host test artifacts') diff --git a/harvester_robot_tests/libs/keywords/common_keywords.py b/harvester_robot_tests/libs/keywords/common_keywords.py index f64d12303..fcf2b2783 100644 --- a/harvester_robot_tests/libs/keywords/common_keywords.py +++ b/harvester_robot_tests/libs/keywords/common_keywords.py @@ -7,9 +7,9 @@ # Add the path to the utility module sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) # noqa E402 -from utility.pod import get_pods_by_label # noqa E402 -from utility.utility import generate_name_with_suffix # noqa E402 -from utility.utility import init_harvester_api_client # noqa E402 +from utility.pod import get_pods_by_label # noqa E402 +from utility.utility import generate_name_with_suffix # noqa E402 +from utility.utility import init_harvester_api_client # noqa E402 from utility.utility import init_k8s_api_client # noqa E402 from utility.utility import logging # noqa E402 @@ -44,7 +44,13 @@ def cleanup_images(self): def cleanup_volumes(self): """Cleanup volumes""" - logging('Cleanup volumes requested') + from volume import Volume + Volume().cleanup() + + def cleanup_storageclasses(self): + """Cleanup storageclasses""" + from storageclass import StorageClass + StorageClass().cleanup() def cleanup_networks(self): """Cleanup networks""" diff --git a/harvester_robot_tests/libs/keywords/host_keywords.py b/harvester_robot_tests/libs/keywords/host_keywords.py index bb91af43f..16a1c0bba 100644 --- a/harvester_robot_tests/libs/keywords/host_keywords.py +++ b/harvester_robot_tests/libs/keywords/host_keywords.py @@ -111,3 +111,18 @@ def get_node_vms(self, node_name): def get_lh_node(self, node_name): """Get Longhorn-specific node details""" return self.host.get_lh_node(node_name) + + def add_lh_node_disk_tag(self, node_name, disk_name, tag): + """Add a tag to a Longhorn node disk""" + return self.host.add_lh_node_disk_tag(node_name, disk_name, tag) + + def remove_lh_node_disk_tag(self, node_name, disk_name, tag): + """Remove a tag from a Longhorn node disk""" + return self.host.remove_lh_node_disk_tag(node_name, disk_name, tag) + + def is_lh_node_disk_tag_present(self, node_name, disk_name, tag): + """Check if a tag exists on a Longhorn node disk""" + lh_node = self.host.get_lh_node(node_name) + disks = (lh_node or {}).get("spec", {}).get("disks", {}) + tags = disks.get(disk_name, {}).get("tags", []) + return tag in tags diff --git a/harvester_robot_tests/libs/keywords/storage_keywords.py b/harvester_robot_tests/libs/keywords/storage_keywords.py index 28e0d3a5a..c07f8a0a4 100644 --- a/harvester_robot_tests/libs/keywords/storage_keywords.py +++ b/harvester_robot_tests/libs/keywords/storage_keywords.py @@ -8,9 +8,9 @@ # Add the path to the utility module sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) # noqa E402 -from constant import LARGE_DISK_BYTE # noqa E402 from utility.utility import logging # noqa E402 from blockdevice import Blockdevice # noqa E402 +from storageclass import StorageClass # noqa E402 from host_keywords import host_keywords # noqa E402 @@ -20,6 +20,7 @@ class storage_keywords: def __init__(self): """Initialize storage keywords with lazy loading""" self._blockdevice = None + self._storageclass = None self._host = None @property @@ -36,42 +37,54 @@ def host(self): self._host = host_keywords() return self._host + @property + def storageclass(self): + """Lazy initialize storageclass to allow API client setup first""" + if self._storageclass is None: + self._storageclass = StorageClass() + return self._storageclass + def list_blockdevices(self, namespace): return self.blockdevice.list(namespace) def get_blockdevice(self, name, namespace): return self.blockdevice.get(name, namespace) - def list_large_blockdevices(self, namespace): + def list_nvme_blockdevices(self, namespace): blockdevices = self.list_blockdevices(namespace) - large_blockdevices = [] + nvme_blockdevices = [] - logging(f"Finding blockdevices in namespace {namespace} >= {LARGE_DISK_BYTE} bytes") + logging(f"Finding NVMe blockdevices in namespace {namespace}") for blockdevice in blockdevices: try: - size_bytes = int( - blockdevice.get("status", {}) - .get("deviceStatus", {}) - .get("capacity", {}) - .get("sizeBytes") + device_status = blockdevice.get("status", {}).get("deviceStatus", {}) + details = device_status.get("details", {}) + storage_controller = str(details.get("storageController", "")).lower() + dev_path = str(device_status.get("devPath", "")).lower() + bus_path = str(details.get("busPath", "")).lower() + + is_nvme = ( + "nvme" in storage_controller or + "nvme" in dev_path or + "nvme" in bus_path ) except Exception: - size_bytes = 0 + is_nvme = False logging( - f"Unable to parse blockdevice sizeBytes for {blockdevice}", level="WARNING" + f"Unable to determine NVMe for blockdevice {blockdevice}", level="WARNING" ) - if size_bytes >= LARGE_DISK_BYTE: - large_blockdevices.append(blockdevice) + if is_nvme: + nvme_blockdevices.append(blockdevice) - logging(f"Found {len(large_blockdevices)} large blockdevices in namespace {namespace}") - return large_blockdevices + logging(f"Found {len(nvme_blockdevices)} NVMe blockdevices in namespace {namespace}") + return nvme_blockdevices - def pick_unprovisioned_data_disks(self, namespace: str) -> dict: + def pick_unprovisioned_nvme_disks(self, namespace: str) -> dict: standard_node_names = self.host.get_standard_nodes() logging(f"Standard nodes: {standard_node_names}") - large_disks = self.list_large_blockdevices(namespace) + large_disks = self.list_nvme_blockdevices(namespace) unprovisioned_disk_by_node = {} for blockdevice in large_disks: node_name = blockdevice.get("spec", {}).get("nodeName") @@ -90,7 +103,7 @@ def pick_unprovisioned_data_disks(self, namespace: str) -> dict: unprovisioned_disk_by_node[node_name] = disk_name - logging(f"Picked unprovisioned data disks: {unprovisioned_disk_by_node}") + logging(f"Picked unprovisioned NVMe disks: {unprovisioned_disk_by_node}") return unprovisioned_disk_by_node def provision_longhorn_storage(self, disk_name: str, engine_version: str, namespace: str): @@ -99,10 +112,10 @@ def provision_longhorn_storage(self, disk_name: str, engine_version: str, namesp def is_blockdevice_provisioned(self, name: str, namespace: str) -> bool: blockdevice = self.get_blockdevice(name, namespace) provision_phase = blockdevice and blockdevice.get("status", {}).get("provisionPhase") - state = blockdevice and blockdevice.get("status", {}).get("state") - if provision_phase == "Provisioned" and state == "Active": + if provision_phase == "Provisioned": return True + logging(f"Blockdevice {namespace}/{name} is not Provisioned but {provision_phase}") return False def get_lh_node(self, node_name: str): @@ -118,3 +131,14 @@ def get_lh_node_disk_status_condition(self, node_name, disk_name, condition_type for condition in disk_status.get("conditions", []): if condition.get("type") == condition_type: return condition.get("status") + + def create_storageclass(self, name, data_engine, number_of_replicas, disk_selector): + return self.storageclass.create( + name, data_engine, number_of_replicas, disk_selector + ) + + def delete_storageclass(self, name): + return self.storageclass.delete(name) + + def get_storageclass(self, name): + return self.storageclass.get(name) diff --git a/harvester_robot_tests/libs/keywords/vm_keywords.py b/harvester_robot_tests/libs/keywords/vm_keywords.py index 56d98e252..a3d13ed6b 100644 --- a/harvester_robot_tests/libs/keywords/vm_keywords.py +++ b/harvester_robot_tests/libs/keywords/vm_keywords.py @@ -22,10 +22,10 @@ def cleanup_vms(self): """Clean up all test VMs""" self.vm.cleanup() - def create_vm(self, vm_name, cpu=2, memory="4Gi", image_id="", **kwargs): + def create_vm(self, vm_name, image_id, cpu=2, memory="4Gi", **kwargs): """Create a virtual machine""" - logging(f'Creating VM {vm_name}') - self.vm.create(vm_name, cpu, memory, image_id, **kwargs) + logging(f'Creating VM {vm_name} with image {image_id}, {cpu}C/{memory} and {kwargs}') + self.vm.create(vm_name, image_id, cpu, memory, **kwargs) def delete_vm(self, vm_name): """Delete a virtual machine""" @@ -115,3 +115,7 @@ def wait_for_backup_completed(self, vm_name, backup_name, timeout=DEFAULT_TIMEOU """Wait for backup to complete""" logging(f'Waiting for backup {backup_name} to complete') self.vm.wait_for_backup_completed(vm_name, backup_name, timeout) + + def update_vm_disk_size(self, vm_name, disk_name, new_size, namespace=DEFAULT_NAMESPACE): + """Update VM disk size via volumeClaimTemplates annotation.""" + self.vm.update_disk_size(vm_name, disk_name, new_size, namespace) diff --git a/harvester_robot_tests/libs/keywords/volume_keywords.py b/harvester_robot_tests/libs/keywords/volume_keywords.py index 79f683f83..ff26bfd87 100644 --- a/harvester_robot_tests/libs/keywords/volume_keywords.py +++ b/harvester_robot_tests/libs/keywords/volume_keywords.py @@ -59,7 +59,6 @@ def wait_for_volume_deleted(self, volume_name, timeout=DEFAULT_TIMEOUT_SHORT): def get_volume_status(self, volume_name): """Get volume status""" - logging(f'Getting status for volume {volume_name}') return self.volume.get_status(volume_name) def list_volumes(self): @@ -69,7 +68,6 @@ def list_volumes(self): def expand_volume(self, volume_name, new_size): """Expand volume size""" - logging(f'Expanding volume {volume_name} to {new_size}') self.volume.expand(volume_name, new_size) def create_volume_snapshot(self, volume_name, snapshot_name): diff --git a/harvester_robot_tests/libs/setting/base.py b/harvester_robot_tests/libs/setting/base.py index 6a37e7676..a2d324c0f 100644 --- a/harvester_robot_tests/libs/setting/base.py +++ b/harvester_robot_tests/libs/setting/base.py @@ -9,12 +9,13 @@ class Base(ABC): """Base class for Setting implementations""" + def __init__(self): + self.unsupported_msg = f"Unsupported by {self.__class__.__name__}, falling back." + @abstractmethod def get(self, setting_id): - """Get setting details""" - pass + raise NotImplementedError(self.unsupported_msg) @abstractmethod def enable(self, setting_id): - """Enable a setting""" - pass + raise NotImplementedError(self.unsupported_msg) diff --git a/harvester_robot_tests/libs/setting/crd.py b/harvester_robot_tests/libs/setting/crd.py index 26ff23f7a..8968a0628 100644 --- a/harvester_robot_tests/libs/setting/crd.py +++ b/harvester_robot_tests/libs/setting/crd.py @@ -17,6 +17,7 @@ class CRD(Base): def __init__(self): """Initialize Kubernetes client""" + super().__init__() self.core_api = client.CoreV1Api() self.custom_api = client.CustomObjectsApi() self.common_parameters = { diff --git a/harvester_robot_tests/libs/setting/rest.py b/harvester_robot_tests/libs/setting/rest.py index dcf3b551e..4ce938c3c 100644 --- a/harvester_robot_tests/libs/setting/rest.py +++ b/harvester_robot_tests/libs/setting/rest.py @@ -3,7 +3,6 @@ Layer 4: Component and its implementation """ -from utility.utility import get_harvester_api_client from .base import Base @@ -11,5 +10,10 @@ class Rest(Base): """REST implementation for Setting operations using Harvester API""" def __init__(self): - self.api_client = get_harvester_api_client() - self.port_forward_process = None + super().__init__() + + def get(self, setting_id): + return super().get(setting_id) + + def enable(self, setting_id): + return super().enable(setting_id) diff --git a/harvester_robot_tests/libs/setting/setting.py b/harvester_robot_tests/libs/setting/setting.py index ef3e8da2a..12aef9382 100644 --- a/harvester_robot_tests/libs/setting/setting.py +++ b/harvester_robot_tests/libs/setting/setting.py @@ -3,8 +3,7 @@ Layer 4: Component and its implementation """ -import os -from constant import HarvesterOperationStrategy +from utility.utility import logging from .base import Base from .crd import CRD from .rest import Rest @@ -12,24 +11,19 @@ class Setting(Base): def __init__(self): - try: - strategy_str = os.getenv("HARVESTER_OPERATION_STRATEGY", "crd").lower() - self._strategy = HarvesterOperationStrategy(strategy_str) - except ValueError: - self._strategy = HarvesterOperationStrategy.CRD - - match self._strategy: - case HarvesterOperationStrategy.CRD: - self.setting = CRD() - case HarvesterOperationStrategy.REST: - self.setting = Rest() + self.crd = CRD() + self.rest = Rest() def get(self, setting_id): - """Get setting details - delegates to implementation""" - setting = self.setting.get(setting_id) - return setting + try: + return self.crd.get(setting_id) + except NotImplementedError as e: + logging(e) + return self.rest.get(setting_id) def enable(self, setting_id): - """Enable a setting - delegates to implementation""" - setting = self.setting.enable(setting_id) - return setting + try: + return self.crd.enable(setting_id) + except NotImplementedError as e: + logging(e) + return self.rest.enable(setting_id) diff --git a/harvester_robot_tests/libs/storageclass/__init__.py b/harvester_robot_tests/libs/storageclass/__init__.py new file mode 100644 index 000000000..e21050f99 --- /dev/null +++ b/harvester_robot_tests/libs/storageclass/__init__.py @@ -0,0 +1,5 @@ +"""StorageClass module for Harvester StorageClass operations""" + +from storageclass.storageclass import StorageClass + +__all__ = ['StorageClass'] diff --git a/harvester_robot_tests/libs/storageclass/base.py b/harvester_robot_tests/libs/storageclass/base.py new file mode 100644 index 000000000..95c1b35ab --- /dev/null +++ b/harvester_robot_tests/libs/storageclass/base.py @@ -0,0 +1,38 @@ +""" StorageClass Component: Base Class + +Layer 4: Component and its implementation +""" + +from abc import ABC, abstractmethod + + +class Base(ABC): + """Base class for StorageClass implementations""" + + def __init__(self): + self.unsupported_msg = f"Unsupported by {self.__class__.__name__}, falling back." + + @abstractmethod + def create(self, name, data_engine, number_of_replicas, disk_selector): + """Create a StorageClass""" + raise NotImplementedError(self.unsupported_msg) + + @abstractmethod + def delete(self, name): + """Delete a StorageClass""" + raise NotImplementedError(self.unsupported_msg) + + @abstractmethod + def get(self, name): + """Get a StorageClass by name""" + raise NotImplementedError(self.unsupported_msg) + + @abstractmethod + def list(self, label_selector=None): + """List StorageClasses with optional label selector""" + raise NotImplementedError(self.unsupported_msg) + + @abstractmethod + def cleanup(self): + """Cleanup test StorageClasses""" + raise NotImplementedError(self.unsupported_msg) diff --git a/harvester_robot_tests/libs/storageclass/crd.py b/harvester_robot_tests/libs/storageclass/crd.py new file mode 100644 index 000000000..e085ff0b8 --- /dev/null +++ b/harvester_robot_tests/libs/storageclass/crd.py @@ -0,0 +1,117 @@ +""" StorageClass Component: CRD Implementation + +Layer 4: Component and its implementation +""" + +from kubernetes import client +from kubernetes.client.rest import ApiException +from utility.utility import logging +from constant import LABEL_TEST, LABEL_TEST_VALUE +from .base import Base + + +class CRD(Base): + """CRD implementation for StorageClass operations using Kubernetes API""" + + def __init__(self): + super().__init__() + self.custom_api = client.CustomObjectsApi() + self.common_parameters = { + "group": "storage.k8s.io", + "version": "v1", + "plural": "storageclasses" + } + + def _normalize_disk_selector(self, disk_selector): + if disk_selector is None: + return "" + parts = [part.strip() for part in str(disk_selector).split(",")] + parts = [part for part in parts if part] + return ",".join(parts) + + def create(self, name, data_engine, number_of_replicas, disk_selector): + if self.get(name): + raise Exception(f"StorageClass {name} already exists") + + disk_selector_value = self._normalize_disk_selector(disk_selector) + body = { + "apiVersion": "storage.k8s.io/v1", + "kind": "StorageClass", + "metadata": { + "name": name, + "annotations": { + "cdi.harvesterhci.io/storageProfileVolumeSnapshotClass": "longhorn-snapshot" + }, + "labels": { + LABEL_TEST: LABEL_TEST_VALUE + } + }, + "provisioner": "driver.longhorn.io", + "allowVolumeExpansion": True, + "reclaimPolicy": "Delete", + "volumeBindingMode": "Immediate", + "parameters": { + "numberOfReplicas": str(number_of_replicas), + "staleReplicaTimeout": "30", + "diskSelector": disk_selector_value, + "encrypted": "false", + "migratable": "true", + "dataEngine": str(data_engine) + } + } + + try: + return self.custom_api.create_cluster_custom_object( + **self.common_parameters, + body=body + ) + except ApiException as e: + raise Exception(f"Failed to create StorageClass {name}: {e}") + + def delete(self, name): + try: + self.custom_api.delete_cluster_custom_object( + **self.common_parameters, + name=name + ) + logging(f"Deleted StorageClass {name}") + except ApiException as e: + if e.status == 404: + return False + raise Exception(f"Failed to delete StorageClass {name}: {e}") + return True + + def get(self, name): + try: + return self.custom_api.get_cluster_custom_object( + **self.common_parameters, + name=name + ) + except ApiException as e: + if e.status == 404: + return None + raise Exception(f"Failed to get StorageClass {name}: {e}") + + def list(self, label_selector=None): + """List VirtualMachines.""" + result = self.custom_api.list_cluster_custom_object( + **self.common_parameters, + label_selector=label_selector + ) + return result.get("items", []) + + def cleanup(self): + try: + scs = self.list( + label_selector=f"{LABEL_TEST}={LABEL_TEST_VALUE}" + ) + + for sc in scs: + try: + sc_name = sc['metadata']['name'] + logging(f'Deleting storage class: {sc_name}') + self.delete(sc_name) + except Exception as e: + logging(f'Error deleting storage class: {sc_name}: {e}', 'WARNING') + except Exception as e: + logging(f'Error during storage class cleanup: {e}', 'WARNING') diff --git a/harvester_robot_tests/libs/storageclass/rest.py b/harvester_robot_tests/libs/storageclass/rest.py new file mode 100644 index 000000000..68086cf86 --- /dev/null +++ b/harvester_robot_tests/libs/storageclass/rest.py @@ -0,0 +1,28 @@ +""" StorageClass Component: REST Implementation + +Layer 4: Component and its implementation +""" + +from .base import Base + + +class Rest(Base): + """REST implementation for StorageClass operations using Harvester API""" + + def __init__(self): + super().__init__() + + def create(self, name, data_engine, number_of_replicas, disk_selector): + return super().add_storageclass(name, data_engine, number_of_replicas, disk_selector) + + def delete(self, name): + return super().delete(name) + + def get(self, name): + return super().get(name) + + def list(self, label_selector=None): + return super().list(label_selector) + + def cleanup(self): + return super().cleanup() diff --git a/harvester_robot_tests/libs/storageclass/storageclass.py b/harvester_robot_tests/libs/storageclass/storageclass.py new file mode 100644 index 000000000..511526b82 --- /dev/null +++ b/harvester_robot_tests/libs/storageclass/storageclass.py @@ -0,0 +1,50 @@ +""" StorageClass Component + +Layer 4: Component and its implementation +""" + +from utility.utility import logging +from .base import Base +from .crd import CRD +from .rest import Rest + + +class StorageClass(Base): + def __init__(self): + self.crd = CRD() + self.rest = Rest() + + def create(self, name, data_engine, number_of_replicas, disk_selector): + try: + return self.crd.create(name, data_engine, number_of_replicas, disk_selector) + except NotImplementedError as e: + logging(e) + return self.rest.create(name, data_engine, number_of_replicas, disk_selector) + + def delete(self, name): + try: + return self.crd.delete(name) + except NotImplementedError as e: + logging(e) + return self.rest.delete(name) + + def get(self, name): + try: + return self.crd.get(name) + except NotImplementedError as e: + logging(e) + return self.rest.get(name) + + def list(self, label_selector=None): + try: + return self.crd.list(label_selector) + except NotImplementedError as e: + logging(e) + return self.rest.list(label_selector) + + def cleanup(self): + try: + return self.crd.cleanup() + except NotImplementedError as e: + logging(e) + return self.rest.cleanup() diff --git a/harvester_robot_tests/libs/utility/utility.py b/harvester_robot_tests/libs/utility/utility.py index 7b8203554..2f1115691 100644 --- a/harvester_robot_tests/libs/utility/utility.py +++ b/harvester_robot_tests/libs/utility/utility.py @@ -54,12 +54,15 @@ def init_harvester_api_client(endpoint, username, password): return _harvester_api_client +def get_timestamp(): + return datetime.now().strftime("%Y%m%d%H%M%S") + + def generate_name_with_suffix(kind, suffix): """Generate unique name with timestamp suffix""" - timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")[:-3] - name = f"{kind}-{suffix}-{timestamp}" - logging(f"Generated name: {name}") - return name + timestamp = datetime.now().strftime("%y%m%d-%H%M%S-%f")[:-2] + name = "-".join([s for s in [kind, suffix, timestamp] if s]) + return name+"qa" # Add 'qa' suffix to all generated names for easier identification def get_retry_count_and_interval(): diff --git a/harvester_robot_tests/libs/vm/base.py b/harvester_robot_tests/libs/vm/base.py index 6592f7630..c05d9140d 100644 --- a/harvester_robot_tests/libs/vm/base.py +++ b/harvester_robot_tests/libs/vm/base.py @@ -9,7 +9,7 @@ class Base(ABC): """Base class for VM implementations""" @abstractmethod - def create(self, vm_name, cpu, memory, image_id, **kwargs): + def create(self, vm_name, image_id, cpu, memory, **kwargs): """Create a virtual machine""" pass @@ -67,3 +67,8 @@ def get_status(self, vm_name): def cleanup(self): """Clean up test resources""" pass + + @abstractmethod + def update_disk_size(self, vm_name, disk_name, new_size, namespace=DEFAULT_NAMESPACE): + """Update VM disk size via volumeClaimTemplates annotation.""" + pass diff --git a/harvester_robot_tests/libs/vm/crd.py b/harvester_robot_tests/libs/vm/crd.py index d7a130faf..540a28032 100644 --- a/harvester_robot_tests/libs/vm/crd.py +++ b/harvester_robot_tests/libs/vm/crd.py @@ -5,12 +5,12 @@ import time from kubernetes import client from kubernetes.client.rest import ApiException -from crd import get_cr, create_cr, delete_cr, list_cr, wait_for_cr_deleted +from crd import get_cr, create_cr, delete_cr, list_cr, wait_for_cr_deleted, replace_cr from constant import ( KUBEVIRT_API_GROUP, KUBEVIRT_API_VERSION, VIRTUALMACHINE_PLURAL, VIRTUALMACHINEINSTANCE_PLURAL, DEFAULT_NAMESPACE, LABEL_TEST, LABEL_TEST_VALUE, - DEFAULT_TIMEOUT_SHORT + DEFAULT_TIMEOUT_SHORT, DEFAULT_STORAGE_CLASS ) from utility.utility import logging, get_retry_count_and_interval from vm.base import Base @@ -29,14 +29,14 @@ def __init__(self): get_retry_count_and_interval() ) - def create(self, vm_name, cpu, memory, image_id, **kwargs): + def create(self, vm_name, image_id, cpu, memory, **kwargs): """Create a VM using CRD""" namespace = kwargs.get('namespace', DEFAULT_NAMESPACE) try: logging(f"Creating VirtualMachine {namespace}/{vm_name}") self._create_virtual_machine( - vm_name, cpu, memory, image_id, namespace, **kwargs + vm_name, image_id, cpu, memory, namespace, **kwargs ) # Wait for VM to be created @@ -51,8 +51,19 @@ def create(self, vm_name, cpu, memory, image_id, **kwargs): raise def _create_virtual_machine( - self, vm_name, cpu, memory, image_id, namespace, **kwargs): - """Create VM matching Harvester's exact structure.""" + self, vm_name, image_id, cpu, memory, namespace, **kwargs): + """Create VM matching Harvester's exact structure. + + extra_disks: optional list of dicts passed via kwargs, each dict may contain: + - size: required, e.g. "10Gi" + - storage_class: optional, defaults to "harvester-longhorn" + - name: optional, defaults to "{vm_name}-disk-{index}" + Example: + extra_disks=[ + {"size": "20Gi", "storage_class": "sc-lhv2"}, + {"name": "data-2", "size": "50Gi"} + ] + """ # Look up the image's actual storage class from Harvester. # Since v1.8.0 (harvester#5165), storage classes use lh- @@ -81,22 +92,94 @@ def _create_virtual_machine( ) # Build volumeClaimTemplates annotation for Harvester + disk_name = "disk-0" + volume_name = f"{vm_name}-{disk_name}" volume_claim_templates = [ { "metadata": { - "name": f"{vm_name}-disk-0", + "name": volume_name, "annotations": { "harvesterhci.io/imageId": f"{namespace}/{image_id}" + }, + "labels": { + LABEL_TEST: LABEL_TEST_VALUE } }, "spec": { "accessModes": ["ReadWriteMany"], - "resources": {"requests": {"storage": "10Gi"}}, + "resources": { + "requests": { + "storage": "10Gi" + } + }, "volumeMode": "Block", "storageClassName": storage_class } } ] + spec_devices_disks = [ + { + "bootOrder": 1, + "name": disk_name, + "disk": { + "bus": "virtio" + } + } + ] + spec_volumes = [ + { + "name": disk_name, + "persistentVolumeClaim": { + "claimName": volume_name + } + } + ] + + extra_disks = kwargs.get("extra_disks", []) + if extra_disks: + if not isinstance(extra_disks, list): + raise Exception("extra_disks must be a list of dicts") + for idx, disk in enumerate(extra_disks, start=1): + volume_name = disk.get("name", f"{vm_name}-disk-{idx}") + volume_claim_templates.append( + { + "metadata": { + "name": volume_name, + "labels": { + LABEL_TEST: LABEL_TEST_VALUE + } + }, + "spec": { + "accessModes": ["ReadWriteMany"], + "resources": { + "requests": { + "storage": disk["size"] + } + }, + "volumeMode": "Block", + "storageClassName": disk.get("storage_class", DEFAULT_STORAGE_CLASS) + } + } + ) + spec_devices_disks.append( + { + "name": volume_name, + "disk": { + "bus": "virtio" + } + } + ) + spec_volumes.append( + { + "name": volume_name, + "persistentVolumeClaim": { + "claimName": volume_name + } + } + ) + + logging(f"Volume claim templates for VM {vm_name}: {volume_claim_templates}") + logging(f"Volume spec_devices_disks for VM {vm_name}: {spec_devices_disks}") # Build complete VM spec matching Harvester structure body = { @@ -139,6 +222,7 @@ def _create_virtual_machine( "threads": 1 }, "devices": { + "disks": spec_devices_disks, "inputs": [ { "bus": "usb", @@ -177,11 +261,13 @@ def _create_virtual_machine( "networks": [ {"name": "default", "pod": {}} ], + "volumes": spec_volumes, "terminationGracePeriodSeconds": 120 } } } } + logging(f"Creating VM with spec: {body}") try: create_cr( @@ -556,3 +642,45 @@ def cleanup(self): logging(f'Error deleting VM {vm_name}: {e}', 'WARNING') except Exception as e: logging(f'Error during VM cleanup: {e}', 'WARNING') + + def _get_disk_template(self, vm_name, disk_name, namespace): + vm = self.get(vm_name, namespace) + annotations = vm.get("metadata", {}).get("annotations", {}) + templates_raw = annotations.get("harvesterhci.io/volumeClaimTemplates") + if not templates_raw: + raise Exception(f"VM {vm_name} has no volumeClaimTemplates annotation") + + try: + volume_claim_templates = json.loads(templates_raw) + except json.JSONDecodeError as e: + raise Exception(f"Invalid volumeClaimTemplates JSON for VM {vm_name}: {e}") + + for template in volume_claim_templates: + if template.get("metadata", {}).get("name") == disk_name: + return vm, annotations, volume_claim_templates, template + raise Exception(f"VM {vm_name} has no disk with PVC name {disk_name}") + + def update_disk_size(self, vm_name, disk_name, new_size, namespace=DEFAULT_NAMESPACE): + """Update VM disk size via volumeClaimTemplates annotation.""" + vm, annotations, volume_claim_templates, target = self._get_disk_template( + vm_name, disk_name, namespace + ) + + # Modify spec with new configuration + target.setdefault( + "spec", {}).setdefault( + "resources", {}).setdefault( + "requests", {})["storage"] = new_size + annotations["harvesterhci.io/volumeClaimTemplates"] = json.dumps(volume_claim_templates) + vm.setdefault("metadata", {})["annotations"] = annotations + + # Apply updated spec + replace_cr( + group=KUBEVIRT_API_GROUP, + version=KUBEVIRT_API_VERSION, + namespace=namespace, + plural=VIRTUALMACHINE_PLURAL, + name=vm_name, + body=vm + ) + logging(f"Updated disk size for {vm_name} {disk_name} to {new_size}") diff --git a/harvester_robot_tests/libs/vm/rest.py b/harvester_robot_tests/libs/vm/rest.py index 54aca1b96..776d443fe 100644 --- a/harvester_robot_tests/libs/vm/rest.py +++ b/harvester_robot_tests/libs/vm/rest.py @@ -17,7 +17,7 @@ def __init__(self): self.retry_count, self.retry_interval = get_retry_count_and_interval() self.checksums = {} - def create(self, vm_name, cpu, memory, image_id, **kwargs): + def create(self, vm_name, image_id, cpu, memory, **kwargs): """Create a virtual machine""" api = get_harvester_api_client() diff --git a/harvester_robot_tests/libs/vm/vm.py b/harvester_robot_tests/libs/vm/vm.py index 097fead48..05b1971e2 100644 --- a/harvester_robot_tests/libs/vm/vm.py +++ b/harvester_robot_tests/libs/vm/vm.py @@ -18,8 +18,8 @@ def __init__(self): else: self.vm = Rest() - def create(self, vm_name, cpu, memory, image_id, **kwargs): - return self.vm.create(vm_name, cpu, memory, image_id, **kwargs) + def create(self, vm_name, image_id, cpu, memory, **kwargs): + return self.vm.create(vm_name, image_id, cpu, memory, **kwargs) def delete(self, vm_name): return self.vm.delete(vm_name) @@ -77,3 +77,6 @@ def wait_for_backup_completed(self, vm_name, backup_name, timeout): def cleanup(self): return self.vm.cleanup() + + def update_disk_size(self, vm_name, disk_name, new_size, namespace=DEFAULT_NAMESPACE): + return self.vm.update_disk_size(vm_name, disk_name, new_size, namespace) diff --git a/harvester_robot_tests/libs/volume/crd.py b/harvester_robot_tests/libs/volume/crd.py index 9dfbfd97a..e6a70af0e 100644 --- a/harvester_robot_tests/libs/volume/crd.py +++ b/harvester_robot_tests/libs/volume/crd.py @@ -143,7 +143,8 @@ def get(self, volume_name, namespace=DEFAULT_NAMESPACE): 'capacity': ( dict(pvc.status.capacity) if pvc.status and pvc.status.capacity else {} - ) + ), + 'conditions': pvc.status.conditions if pvc.status.conditions else [] } } except ApiException as e: @@ -257,9 +258,10 @@ def get_status(self, volume_name): status = pvc.get('status', {}) return { - 'state': status.get('phase', 'Unknown'), + 'phase': status.get('phase', 'Unknown'), 'capacity': status.get('capacity', {}), - 'access_modes': pvc.get('spec', {}).get('accessModes', []) + 'access_modes': pvc.get('spec', {}).get('accessModes', []), + 'conditions': status.get('conditions', []) } def expand(self, volume_name, new_size): @@ -409,7 +411,8 @@ def cleanup(self): try: logging(f'Deleting test PVC: {namespace}/{pvc_name}') - self.delete(pvc_name, wait=False) + # Wait to avoid issues when following cleanup like image and storageclass + self.delete(pvc_name, wait=True) except Exception as e: logging(f'Error deleting PVC {pvc_name}: {e}', "WARNING") except Exception as e: diff --git a/harvester_robot_tests/tests/regression/test_longhorn_v2.robot b/harvester_robot_tests/tests/regression/test_longhorn_v2.robot index abf7d3270..9f0b620f7 100644 --- a/harvester_robot_tests/tests/regression/test_longhorn_v2.robot +++ b/harvester_robot_tests/tests/regression/test_longhorn_v2.robot @@ -1,31 +1,100 @@ *** Settings *** Documentation Longhorn V2 Data Engine Test Cases -Test Tags longhorn LHv2 LonghornV2 storage experimental +Test Tags storage longhorn lhv2 experimental -Resource ../../keywords/variables.resource -Resource ../../keywords/setting.resource +Resource ../../keywords/variables.resource +Resource ../../keywords/setting.resource +Resource ../../keywords/image.resource +Resource ../../keywords/virtualmachine.resource +Resource ../../keywords/volume.resource +Resource ../../keywords/storageclass.resource Suite Setup Local Suite Setup -Suite Teardown Cleanup test resources +Suite Teardown Local Suite Teardown +Test Teardown Common Test Teardown *** Variables *** -&{DATA_DISK_BY_NODE} &{EMPTY} -${LHv2_SC_REPLICAS} ${EMPTY} +${LHv2_TAG} lhv2 +# Dynamic Variables +&{DISK_BY_NODE} &{EMPTY} +${SC_REPLICAS} ${EMPTY} +${SC_NAME} ${EMPTY} +${IMAGE_NAME} ${EMPTY} +${VM_NAME} ${EMPTY} +${VOL_NAME} ${EMPTY} *** Test Cases *** Provision LHv2 Storages + [Tags] p0 + [Documentation] Provision at most 3 NVMe storages with LHv2 Data Engine + When Provision Storages With LHv2 Data Engine ${DISK_BY_NODE} + Then Wait Until Storages Are Provisioned ${DISK_BY_NODE} + +Create LHv2 Storage Class + [Tags] p0 + When Tag Storages ${LHv2_TAG} ${DISK_BY_NODE} + Then Create LHv2 Storage Class ${SC_NAME} ${SC_REPLICAS} ${LHv2_TAG} + +Create VM With LHv2 Volume + [Tags] p0 + Given Storage Class Is Present ${SC_NAME} + When Create VM with a 10Gi LHv2 volume ${VM_NAME} + Then VM should be running ${VM_NAME} + And VM should have IP addresses ${VM_NAME} + And Volume size is 10Gi ${VOL_NAME} + +Expand LHv2 Volume Size + [Tags] p1 + Given Volume size is 10Gi ${VOL_NAME} + When Update volume size to 20Gi ${VOL_NAME} + And Wait until volume is active ${VOL_NAME} + Then Volume size is 20Gi ${VOL_NAME} + +Expand LHv2 Volume Size From VM [Tags] p1 - [Documentation] Provision at most 3 storages with LHv2 Data Engine - When Provision Storages With LHv2 Data Engine &{DATA_DISK_BY_NODE} - Then Wait Until LHv2 Storages Are Provisioned &{DATA_DISK_BY_NODE} + Given Volume size is 20Gi ${VOL_NAME} + When Update volume size to 30Gi from VM ${VM_NAME} ${VOL_NAME} + And Wait until volume is active ${VOL_NAME} + Then Volume size is 30Gi ${VOL_NAME} *** Keywords *** Local Suite Setup - Given Set up test environment - And storage.Cluster Has Available Data Disks And Set Suite Variables - And setting.LHv2 Data Engine Is Disabled - When setting.Enable LHv2 Data Engine - Then setting.Wait Until LHv2 Data Engine Is Enabled + # Variable initialization + ${suffix}= Generate Unique Name ${LHv2_TAG} + Set Suite Variable ${SC_NAME} sc-${suffix} + Set Suite Variable ${IMAGE_NAME} img-${suffix} + Set Suite Variable ${VM_NAME} vm-${suffix} + Set Suite Variable ${VOL_NAME} vol-${suffix} + + Set up test environment + # DISK_BY_NODE + &{nvme_by_node}= storage.Get Available NVMe Disks + Set Suite Variable &{DISK_BY_NODE} &{nvme_by_node} + Set Suite Metadata DISK_BY_NODE ${DISK_BY_NODE} + # SC_REPLICAS + ${nvme_count}= Get Length ${DISK_BY_NODE} + Set Suite Variable ${SC_REPLICAS} ${nvme_count} + Set Suite Metadata SC_REPLICAS ${SC_REPLICAS} + + ${is_enabled}= Run Keyword And Return Status setting.LHv2 Data Engine Is Enabled + IF not ${is_enabled} + setting.Enable LHv2 Data Engine + setting.Wait Until LHv2 Data Engine Is Enabled + END + Image is available for VM creation ${IMAGE_NAME} ${UBUNTU_IMAGE_URL} + +Local Suite Teardown + Common Suite Teardown + +Create VM With A ${size_gi}Gi LHv2 Volume + [Arguments] ${vm_name} + # TODO: See if there is a better way use suite variables in keywords without passing them as arguments + &{extra_disk}= Create Dictionary + ... name=${VOL_NAME} + ... size=${size_gi}Gi + ... storage_class=${SC_NAME} + @{extra_disks}= Create List ${extra_disk} + virtualmachine.VM is created ${vm_name} ${IMAGE_NAME} extra_disks=${extra_disks} diff --git a/harvester_robot_tests/tests/regression/test_vm.robot b/harvester_robot_tests/tests/regression/test_vm.robot index 4e826a702..c715d3f94 100644 --- a/harvester_robot_tests/tests/regression/test_vm.robot +++ b/harvester_robot_tests/tests/regression/test_vm.robot @@ -4,10 +4,14 @@ Test Tags regression virtualmachines Resource ../../keywords/variables.resource Resource ../../keywords/common.resource +Resource ../../keywords/image.resource Resource ../../keywords/virtualmachine.resource -Test Setup Set up test environment + +Suite Setup Set up test environment Test Teardown Cleanup test resources +Suite Teardown Common Suite Teardown + *** Test Cases *** Test VM Basic Lifecycle @@ -15,12 +19,12 @@ Test VM Basic Lifecycle [Documentation] Test basic VM creation, start, stop, delete operations # Generate unique names for vm & image using timestamp - ${timestamp}= Get Current Date result_format=%Y%m%d%H%M%S%f - ${image_name}= Set Variable image-0-${timestamp} - ${vm_name}= Set Variable vm-0-${timestamp} + ${suffix}= Generate Unique Name + ${image_name}= Set Variable image-0-${suffix} + ${vm_name}= Set Variable vm-0-${suffix} Given Image is available for VM creation ${image_name} ${OPENSUSE_IMAGE_URL} - When VM is created ${vm_name} cpu=2 memory=4Gi image_id=${image_name} + When VM is created ${vm_name} ${image_name} Then VM should be running ${vm_name} And VM should have IP addresses ${vm_name} ${DEFAULT_NAMESPACE} When VM is stopped ${vm_name}