From ef4a9bae0a626bfb3b7caf3da2ed26d2e6500112 Mon Sep 17 00:00:00 2001 From: sr73318 Date: Thu, 24 Sep 2026 14:08:05 +0530 Subject: [PATCH] CSTACKEX-301: automation for cs volume resize --- test/integration/plugins/ontap/README.md | 12 +- test/integration/plugins/ontap/TEST_CASES.md | 44 ++-- .../iscsi/instance/test_vm_volume_attach.py | 246 ++++++++++++++++-- .../nfs3/instance/test_vm_volume_attach.py | 243 +++++++++++++++-- .../plugins/ontap/ontap_test_base.py | 163 ++++++++++-- 5 files changed, 633 insertions(+), 75 deletions(-) diff --git a/test/integration/plugins/ontap/README.md b/test/integration/plugins/ontap/README.md index 6e0d0e7d6be5..1dadf2c58fee 100644 --- a/test/integration/plugins/ontap/README.md +++ b/test/integration/plugins/ontap/README.md @@ -32,7 +32,7 @@ CI wiring: test/integration/plugins/ontap/ ├── ontap.cfg # Environment config (IPs, credentials, zone info) ├── ontap_test_base.py # Shared base class and ONTAP REST client -├── TEST_CASES.md # Full test case reference table (62 tests) +├── TEST_CASES.md # Full test case reference table (70 tests) ├── README.md # This file │ ├── nfs3/ @@ -43,7 +43,7 @@ test/integration/plugins/ontap/ │ ├── volume/ │ │ └── test_volume_lifecycle.py # Volume create/delete/negative-delete │ └── instance/ -│ └── test_vm_volume_attach.py # Pool + volume + VM + attach/detach +│ └── test_vm_volume_attach.py # Pool + volume + VM + attach/resize/detach │ └── iscsi/ ├── pool/ @@ -53,7 +53,7 @@ test/integration/plugins/ontap/ ├── volume/ │ └── test_volume_lifecycle.py # LUN create/delete/negative-delete └── instance/ - └── test_vm_volume_attach.py # Pool + LUN + VM + attach/LUN-map lifecycle + └── test_vm_volume_attach.py # Pool + LUN + VM + attach/resize lifecycle ``` --- @@ -308,12 +308,12 @@ self.assertEqual(result.state, "Maintenance") | NFS3 Pool with Volumes | `nfs3/pool/test_pool_with_volumes.py` | 7 | Same + live volume present; negative delete guard | | NFS3 Zone-Scoped Pool | `nfs3/pool/test_zone_scoped_pool.py` | 4 | Zone scope — all hosts connected via `attachZone` | | NFS3 Volume Lifecycle | `nfs3/volume/test_volume_lifecycle.py` | 5 | Volume is metadata-only; FlexVol unchanged on delete | -| NFS3 VM + Volume Attach | `nfs3/instance/test_vm_volume_attach.py` | 8 | Full VM lifecycle with hot-plug/detach | +| NFS3 VM + Volume Attach | `nfs3/instance/test_vm_volume_attach.py` | 12 | Full VM lifecycle with stopped-VM qcow2 grow | | iSCSI Pool Lifecycle | `iscsi/pool/test_pool_lifecycle.py` | 8 | Create, disable, enable, maintenance, delete + igroups | | iSCSI Pool with Volumes | `iscsi/pool/test_pool_with_volumes.py` | 7 | Same + live LUN present; negative delete guard | | iSCSI Zone-Scoped Pool | `iscsi/pool/test_zone_scoped_pool.py` | 4 | Zone scope | | iSCSI Volume Lifecycle | `iscsi/volume/test_volume_lifecycle.py` | 5 | LUN created per CS volume; LUN removed on delete | -| iSCSI VM + Volume Attach | `iscsi/instance/test_vm_volume_attach.py` | 8 | Full VM lifecycle; LUN-maps on VM start/stop/detach | +| iSCSI VM + Volume Attach | `iscsi/instance/test_vm_volume_attach.py` | 12 | Full VM lifecycle; stopped-VM LUN grow and LUN-map checks | For the goal, dependencies, and exact success criteria of every individual test, see [TEST_CASES.md](TEST_CASES.md). @@ -328,7 +328,7 @@ For the goal, dependencies, and exact success criteria of every individual test, | `Lost connection to MySQL` | MySQL not accepting remote connections | Enable remote MySQL access (see Prerequisites §3) | | `sh: python: command not found` (repeated) | Marvin internal call — harmless on macOS | Ignore; Marvin Init still succeeds | | Pool state never reaches `Maintenance` | KVM agent not responding | Check `cloudstack-agent` on KVM host; verify host is connected in CloudStack UI | -| iSCSI `test_07` error 530 | KVM guest does not ACK SCSI hot-unplug | Known environment limitation — see TEST_CASES.md Suite 10 note | +| iSCSI `test_10` error 530 | KVM guest does not ACK SCSI hot-unplug | Known environment limitation — see TEST_CASES.md Suite 10 note | | ONTAP REST `401 Unauthorized` | Wrong credentials in `ontap.cfg` | Verify `username`/`password` under `ontap` section | | `No ready KVM user template available` | Template still downloading | Re-run `setup_zone` (step 12 waits for template readiness); or wait in CloudStack UI | | `setup_zone` steps 11–12 slow on first run | System VMs and template download after zone enable | Normal — first run may take up to ~60 min; re-runs pass quickly when already ready | diff --git a/test/integration/plugins/ontap/TEST_CASES.md b/test/integration/plugins/ontap/TEST_CASES.md index 73dc1990a5b6..8a85d46a3974 100644 --- a/test/integration/plugins/ontap/TEST_CASES.md +++ b/test/integration/plugins/ontap/TEST_CASES.md @@ -19,7 +19,7 @@ # ONTAP Integration Test Cases -Complete reference for all 62 test cases across 10 test suites. +Complete reference for all 70 test cases across 10 test suites. Each suite is sequential — tests must run in numbered order; each step builds on state created by the previous step. --- @@ -114,18 +114,22 @@ Each suite is sequential — tests must run in numbered order; each step builds **File:** `nfs3/instance/test_vm_volume_attach.py` **Class:** `TestOntapVMVolumeAttach` **Tag:** `vm_volume_workflow` -**Total:** 8 tests | **Scope:** end-to-end — NFS3 pool, data volume, running VM, attach/detach lifecycle +**Total:** 12 tests | **Scope:** end-to-end — NFS3 pool, data volume resize, VM attach/detach lifecycle | # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type | |---|-------------|------|------------|-----------------------------|------------------------|------| | 01 | `test_01_create_nfs3_pool` | Create NFS3 ONTAP primary storage pool | setUpClass (zone, cluster, template) | `pool.state == "Up"` | FlexVol `online`; export policy present | positive | | 02 | `test_02_create_ontap_data_volume` | Allocate a CloudStack data volume on the ONTAP pool | test_01 (`pool`) | Volume non-None and listed in `listVolumes` | FlexVol still `online` | positive | -| 03 | `test_03_deploy_vm` | Deploy a VM using the first available ready KVM template | test_02 (`pool`, `volume`) | `vm.state == "Running"`; template auto-selected from `listTemplates` | n/a | positive | +| 03 | `test_03_deploy_vm` | Deploy a VM using the template and service offering discovered at setup time | test_02 (`pool`, `volume`) | VM boots and reaches `Running` | FlexVol still `online` | positive | | 04 | `test_04_attach_volume_to_vm` | Attach the ONTAP data volume to the running VM (hot-plug) | test_03 (`vm`, `volume`) | `volume.virtualmachineid == vm.id`; `attachVolume` job succeeds | FlexVol `online`; after attach, a data file matching volume UUID present in FlexVol (`list_files_in_volume`) | positive | -| 05 | `test_05_stop_vm_export_retained` | Stop the running VM with volume attached | test_04 | `vm.state == "Stopped"` | FlexVol still `online`; NFS export policy still present | positive | -| 06 | `test_06_start_vm_volume_accessible` | Start the stopped VM | test_05 | `vm.state == "Running"` | FlexVol still `online` | positive | -| 07 | `test_07_detach_volume_from_vm` | Hot-detach the ONTAP volume from the running VM (TDS Detach NFS3) | test_06 (`vm`, `volume`) | `volume.virtualmachineid` cleared; `volume.state == "Ready"` | FlexVol still `online`; data file **still present** (NFS3: file persists until `deleteVolume`, not on detach) | positive | -| 08 | `test_08_destroy_vm_and_cleanup` | Destroy VM (expunge), delete volume, enter maintenance, delete pool | test_07 | VM no longer listed; volume no longer listed; pool no longer listed | FlexVol deleted; export policy deleted | cleanup | +| 05 | `test_05_resize_running_vm_rejected` | Attempt to grow an attached managed volume while its VM is running | test_04 | `resizeVolume` is rejected; volume size remains unchanged | qcow2 virtual size remains unchanged | negative | +| 06 | `test_06_stop_vm_export_retained` | Stop the running VM with volume attached | test_05 | `vm.state == "Stopped"` | FlexVol still `online`; NFS export policy still present | positive | +| 07 | `test_07_grow_stopped_vm_volume` | Grow the attached volume while the VM is stopped | test_06 | Volume is `Ready` at the requested byte size | `qemu-img info` reports the requested virtual size; ONTAP file remains present | positive | +| 08 | `test_08_shrink_rejected` | Attempt to shrink the grown qcow2 volume with `shrinkok=true` | test_07 | Resize is rejected; grown size remains unchanged | qcow2 virtual size remains unchanged | negative | +| 09 | `test_09_start_vm_volume_accessible` | Start the stopped VM | test_08 | `vm.state == "Running"`; KVM data-disk capacity equals target | FlexVol still `online` | positive | +| 10 | `test_10_detach_volume_from_vm` | Hot-detach the ONTAP volume from the running VM (TDS Detach NFS3) | test_09 (`vm`, `volume`) | `volume.virtualmachineid` cleared; `volume.state == "Ready"` | FlexVol still `online`; data file **still present** (NFS3: file persists until `deleteVolume`, not on detach) | positive | +| 11 | `test_11_grow_detached_volume` | Grow the detached, materialized data volume | test_10 | API and DB size equal target | qcow2 virtual size equals target | positive | +| 12 | `test_12_destroy_vm_and_cleanup` | Destroy VM (expunge), delete volume, enter maintenance, delete pool | test_11 | VM no longer listed; volume no longer listed; pool no longer listed | FlexVol deleted; export policy deleted | cleanup | --- @@ -206,20 +210,24 @@ Each suite is sequential — tests must run in numbered order; each step builds **File:** `iscsi/instance/test_vm_volume_attach.py` **Class:** `TestOntapVMVolumeAttachISCSI` **Tag:** `iscsi_vm_workflow` -**Total:** 8 tests | **Scope:** end-to-end — iSCSI pool, data volume (LUN), running VM, attach/stop/start/detach lifecycle +**Total:** 12 tests | **Scope:** end-to-end — iSCSI pool, data volume resize, VM attach/detach lifecycle | # | Test method | Goal | Depends on | CloudStack success criteria | ONTAP success criteria | Type | |---|-------------|------|------------|-----------------------------|------------------------|------| | 01 | `test_01_create_iscsi_pool` | Create iSCSI ONTAP primary storage pool | setUpClass | `pool.state == "Up"`, `pool.type == "OntapiSCSI"` | FlexVol `online`; igroup per cluster host with host IQN | positive | -| 02 | `test_02_create_ontap_data_volume` | Allocate a CloudStack data volume (creates a LUN in the FlexVol) | test_01 (`pool`) | Volume non-None | ≥1 LUN in FlexVol | positive | -| 03 | `test_03_deploy_vm` | Deploy VM using first ready KVM template; verify 0 LUN-maps exist before attach | test_02 (`volume`) | `vm.state == "Running"`; 0 LUN-maps on ONTAP | 0 LUN-maps (`list_lun_maps_for_volume` returns empty) | positive | +| 02 | `test_02_create_ontap_data_volume` | Allocate and grow a detached data volume | test_01 (`pool`) | API and DB size equal target | LUN exists and `space.size` equals target | positive | +| 03 | `test_03_deploy_vm` | Deploy a VM using the template and service offering discovered at setup time | test_02 (`volume`) | VM boots and reaches `Running` | data LUN is not yet mapped (no attach performed) | positive | | 04 | `test_04_attach_volume_to_vm` | Hot-attach the ONTAP iSCSI volume to the running VM — a LUN-map is created (TDS SN 27) | test_03 (`vm`, `volume`) | `volume.virtualmachineid == vm.id` | ≥1 LUN-map linking the LUN to the host's igroup | positive | -| 05 | `test_05_stop_vm_lun_unmapped` | Stop VM — LUN-maps must be removed (TDS VM Stop iSCSI) | test_04 | `vm.state == "Stopped"` | 0 LUN-maps; LUN itself **still present** in FlexVol | positive | -| 06 | `test_06_start_vm_lun_remapped` | Start VM — LUN-maps must be re-created (TDS VM Start iSCSI) | test_05 | `vm.state == "Running"` | ≥1 LUN-map re-created | positive | -| 07 | `test_07_detach_volume_from_vm` | Hot-detach the iSCSI volume from the running VM (TDS Detach iSCSI) | test_06 (`vm`, `volume`) | `volume.virtualmachineid` cleared | 0 LUN-maps; LUN still in FlexVol | positive ⚠️ | -| 08 | `test_08_destroy_vm_and_cleanup` | Destroy VM (expunge), delete volume, enter maintenance, delete pool | test_07 | VM gone; volume gone; pool gone | FlexVol deleted; all LUNs and igroups deleted | cleanup | +| 05 | `test_05_resize_running_vm_rejected` | Attempt to grow the attached managed LUN while its VM is running | test_04 | `resizeVolume` is rejected; volume size remains unchanged | LUN `space.size` remains unchanged | negative | +| 06 | `test_06_stop_vm_lun_unmapped` | Stop VM — LUN-maps must be removed (TDS VM Stop iSCSI) | test_05 | `vm.state == "Stopped"` | 0 LUN-maps; LUN itself **still present** in FlexVol | positive | +| 07 | `test_07_grow_stopped_vm_volume` | Grow the attached volume while the VM is stopped | test_06 | Volume is `Ready` at the requested byte size | LUN `space.size` equals the requested size; LUN remains unmapped | positive | +| 08 | `test_08_shrink_rejected` | Attempt to shrink the grown LUN with `shrinkok=true` | test_07 | Resize is rejected; grown size remains unchanged | LUN `space.size` remains unchanged | negative | +| 09 | `test_09_start_vm_lun_remapped` | Start VM — LUN-maps must be re-created (TDS VM Start iSCSI) | test_08 | `vm.state == "Running"`; KVM data-disk capacity equals target | ≥1 LUN-map re-created | positive | +| 10 | `test_10_detach_volume_from_vm` | Hot-detach the iSCSI volume from the running VM (TDS Detach iSCSI) | test_09 (`vm`, `volume`) | `volume.virtualmachineid` cleared | 0 LUN-maps; LUN still in FlexVol | positive ⚠️ | +| 11 | `test_11_grow_detached_volume` | Grow the detached, materialized data volume | test_10 | API and DB size equal target | LUN `space.size` equals target | positive | +| 12 | `test_12_destroy_vm_and_cleanup` | Destroy VM (expunge), delete volume, enter maintenance, delete pool | test_11 | VM gone; volume gone; pool gone | FlexVol deleted; all LUNs and igroups deleted | cleanup | -> ⚠️ **test_07 known status:** iSCSI hot-detach from a running VM relies on the KVM guest acknowledging the SCSI device removal. On this environment the guest does not acknowledge in time, causing CloudStack error 530. This is a KVM-host-level or guest-template limitation, not a test code defect. All other 61 tests pass. +> ⚠️ **test_10 known status:** iSCSI hot-detach from a running VM relies on the KVM guest acknowledging the SCSI device removal. On this environment the guest does not acknowledge in time, causing CloudStack error 530. This is a KVM-host-level or guest-template limitation, not a test code defect. --- @@ -231,10 +239,10 @@ Each suite is sequential — tests must run in numbered order; each step builds | NFS3 Pool with Volumes | NFS3 | Cluster | 7 | ✅ | | NFS3 Zone-Scoped Pool | NFS3 | Zone | 4 | ✅ | | NFS3 Volume Lifecycle | NFS3 | Cluster | 5 | ✅ | -| NFS3 VM + Volume Attach | NFS3 | Cluster | 8 | ✅ | +| NFS3 VM + Volume Attach | NFS3 | Cluster | 12 | ⏳ resize cases pending lab validation | | iSCSI Pool Lifecycle | iSCSI | Cluster | 8 | ✅ | | iSCSI Pool with Volumes | iSCSI | Cluster | 7 | ✅ | | iSCSI Zone-Scoped Pool | iSCSI | Zone | 4 | ✅ | | iSCSI Volume Lifecycle | iSCSI | Cluster | 5 | ✅ | -| iSCSI VM + Volume Attach | iSCSI | Cluster | 8 | ⚠️ 7/8 | -| **Total** | | | **62** | **61 passing** | +| iSCSI VM + Volume Attach | iSCSI | Cluster | 12 | ⏳ resize cases pending lab validation; detach limitation | +| **Total** | | | **70** | **New resize cases pending lab validation** | diff --git a/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py b/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py index 4c843bfbd90f..d562f747a2eb 100644 --- a/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py +++ b/test/integration/plugins/ontap/iscsi/instance/test_vm_volume_attach.py @@ -34,18 +34,22 @@ - startVirtualMachine → LUN-map is re-created - detachVolume → LUN-map is removed -Tests are numbered test_01 ... test_08 and must run in that order. Each step +Tests are numbered test_01 ... test_12 and must run in that order. Each step builds on the shared state established by the previous step. Workflow: 01 Create iSCSI primary storage pool on ONTAP 02 Create a CloudStack data volume on the iSCSI pool (LUN on ONTAP) - 03 Deploy a VM using any available KVM template + 03 Deploy a VM (template and service offering discovered at setup time) 04 Attach iSCSI data volume to running VM (LUN-map created) - 05 Stop VM — LUN-map for attached volume is removed from ONTAP - 06 Start VM — LUN-map is re-created on ONTAP - 07 Detach data volume from running VM — LUN-map removed - 08 Destroy VM, delete data volume, delete pool + 05 Verify resize is rejected while the attached VM is running + 06 Stop VM — LUN-map for attached volume is removed from ONTAP + 07 Grow the attached volume and ONTAP LUN while the VM is stopped + 08 Verify shrinking the ONTAP LUN is rejected without changing its size + 09 Start VM — LUN-map is re-created on ONTAP + 10 Detach data volume from running VM — LUN-map removed + 11 Grow the detached data volume + 12 Destroy VM, delete data volume, delete pool Prerequisites: - CloudStack management server with the NetApp ONTAP plugin deployed @@ -193,6 +197,8 @@ class TestOntapVMVolumeAttachISCSI(OntapTestBase): service_offering_id = None network_id = None _created_network_id = None # network created by this suite for Advanced zones + initial_volume_size = None + target_volume_size = None _vol_name_prefix = "OntapISCSIVM" @@ -303,7 +309,7 @@ def setUpClass(cls): def tearDownClass(cls): """ Safety-net cleanup: destroy VM if still alive, delete the guest - network created for Advanced zones (if not already deleted by test_08), + network created for Advanced zones (if not already deleted by test_11), then delegate pool/volume/account cleanup to the base class. """ if cls.vm is not None: @@ -332,7 +338,7 @@ def tearDownClass(cls): % (cls.vm.id, e)) # Delete the guest network created for this account in Advanced zones. - # test_08 deletes it on the happy path; this is the fallback for + # test_11 deletes it on the happy path; this is the fallback for # mid-suite failures. if cls._created_network_id is not None: try: @@ -379,7 +385,15 @@ def _poll_vm_state(self, vm_id, target_state, timeout=300, interval=10): deadline = time.time() + timeout current_state = "unknown" while time.time() < deadline: - vms = self.apiClient.listVirtualMachines(_list_vms_cmd(vm_id)) + try: + vms = self.apiClient.listVirtualMachines( + _list_vms_cmd(vm_id)) + except Exception as exc: + logger.warning( + "Transient API error while waiting for VM %s to reach " + "'%s': %s", vm_id, target_state, exc) + time.sleep(interval) + continue if vms: current_state = vms[0].state if current_state.lower() == target_state.lower(): @@ -441,6 +455,17 @@ def _data_luns(self, volume=None): self.svm_name, self.__class__.pool.name) if lun.get("name") == lun_name] + def _data_lun_size(self): + """Return the ONTAP data LUN size in bytes.""" + lun_name = self._data_lun_name() + lun = self.ontap.get_lun(self.svm_name, lun_name) + self.assertIsNotNone( + lun, "ONTAP LUN '%s' was not found" % lun_name) + size = int(lun.get("space", {}).get("size", 0)) + self.assertGreater( + size, 0, "ONTAP LUN '%s' did not report space.size" % lun_name) + return size + # ================================================================== # Test steps # ================================================================== @@ -480,10 +505,12 @@ def test_01_create_iscsi_pool(self): @attr(tags=["iscsi_vm_workflow"], required_hardware=True) def test_02_create_ontap_data_volume(self): """ - Allocate a CloudStack data volume on the iSCSI ONTAP pool. + Allocate and grow a detached CloudStack data volume on the iSCSI + ONTAP pool. Verifies: - createVolume returns a volume object - ONTAP: at least one LUN is created inside the pool's FlexVol + - detached resize updates CloudStack API, DB, and LUN space.size """ self.assertIsNotNone(self.__class__.pool, "Pool absent — test_01 must pass first") @@ -500,6 +527,19 @@ def test_02_create_ontap_data_volume(self): % (self._data_lun_name(), self.__class__.pool.name) ) + volume = self._poll_volume_size(vol.id, int(vol.size)) + detached_target = ( + (int(volume.size) // (1024 ** 3)) + 1 + ) * (1024 ** 3) + self._resize_volume(vol.id, detached_target // (1024 ** 3)) + self._poll_volume_size(vol.id, detached_target) + self.assertEqual( + self._get_db_volume_size(vol.id), detached_target, + "DB size does not match detached iSCSI grow target") + self.assertEqual( + self._data_lun_size(), detached_target, + "ONTAP LUN size does not match detached grow target") + # ------------------------------------------------------------------ # Step 03 — Deploy a VM # ------------------------------------------------------------------ @@ -588,12 +628,61 @@ def test_04_attach_volume_to_vm(self): "LUN is not accessible to the VM's host" ) + initial_size = int(result.size) + self.__class__.initial_volume_size = initial_size + self.__class__.target_volume_size = ( + (initial_size // (1024 ** 3)) + 1 + ) * (1024 ** 3) + self.assertEqual( + self._data_lun_size(), initial_size, + "Initial ONTAP LUN size should match the CloudStack volume size") + + # ------------------------------------------------------------------ + # Step 05 — Resize attached volume while VM is running must fail + # ------------------------------------------------------------------ + + @attr(tags=["iscsi_vm_workflow"], required_hardware=True) + def test_05_resize_running_vm_rejected(self): + """ + Attempt to grow the attached iSCSI volume while its managed KVM VM is + running. + Verifies: + - CloudStack rejects the resize + - CloudStack volume and ONTAP LUN sizes remain unchanged + """ + if self.__class__.vm is None: + self.skipTest("VM not deployed — test_03 was skipped") + self.assertIsNotNone(self.__class__.volume, + "Volume absent — test_02 must pass first") + + target_gib = self.__class__.target_volume_size // (1024 ** 3) + with self.assertRaises(Exception) as context: + self._resize_volume(self.__class__.volume.id, target_gib) + self.assertIn( + "not in the Stopped state", str(context.exception), + "Resize failed for an unexpected reason") + + volume = self._poll_volume_size( + self.__class__.volume.id, self.__class__.initial_volume_size) + self.assertIsNotNone( + volume, "CloudStack volume size changed after rejected resize") + + # DB: size must remain unchanged after a rejected resize + db_size = self._get_db_volume_size(self.__class__.volume.id) + self.assertEqual( + db_size, self.__class__.initial_volume_size, + "cloud DB volumes.size changed after rejected running-VM resize") + + self.assertEqual( + self._data_lun_size(), self.__class__.initial_volume_size, + "ONTAP LUN size changed after rejected running-VM resize") + # ------------------------------------------------------------------ - # Step 05 — Stop VM — LUN-maps should be removed + # Step 06 — Stop VM — LUN-maps should be removed # ------------------------------------------------------------------ @attr(tags=["iscsi_vm_workflow"], required_hardware=True) - def test_05_stop_vm_lun_unmapped(self): + def test_06_stop_vm_lun_unmapped(self): """ Stop the running VM while the iSCSI data volume is still attached. Covers TDS VM Stop (iSCSI): 'Luns for the volumes under this VM @@ -607,7 +696,15 @@ def test_05_stop_vm_lun_unmapped(self): cmd = stopVirtualMachineAPI.stopVirtualMachineCmd() cmd.id = self.__class__.vm.id - self.apiClient.stopVirtualMachine(cmd) + try: + self.apiClient.stopVirtualMachine(cmd) + except Exception as exc: + # Marvin can lose the management API connection while polling an + # already-submitted async stop job. The VM state is authoritative, + # so continue polling before treating the stop as failed. + logger.warning( + "stopVirtualMachine polling failed; checking VM state: %s", + exc) result = self._poll_vm_state(self.__class__.vm.id, "Stopped", timeout=300) @@ -632,11 +729,99 @@ def test_05_stop_vm_lun_unmapped(self): ) # ------------------------------------------------------------------ - # Step 06 — Start VM — LUN-maps should be re-created + # Step 07 — Grow attached volume while VM is stopped # ------------------------------------------------------------------ @attr(tags=["iscsi_vm_workflow"], required_hardware=True) - def test_06_start_vm_lun_remapped(self): + def test_07_grow_stopped_vm_volume(self): + """ + Grow the attached iSCSI volume while its managed KVM VM is stopped. + Verifies: + - CloudStack reports the target size and Ready state + - ONTAP reports the target LUN space.size + - The LUN remains unmapped while the VM is stopped + """ + if self.__class__.vm is None: + self.skipTest("VM not deployed — test_03 was skipped") + self.assertIsNotNone(self.__class__.volume, + "Volume absent — test_02 must pass first") + + target_size = self.__class__.target_volume_size + self._resize_volume( + self.__class__.volume.id, target_size // (1024 ** 3)) + + volume = self._poll_volume_size( + self.__class__.volume.id, target_size) + self.assertIsNotNone( + volume, "CloudStack did not report the grown volume size") + + # DB: cloud.volumes.size must be updated by the driver's volumeDao call + db_size = self._get_db_volume_size(self.__class__.volume.id) + self.assertEqual( + db_size, target_size, + "cloud DB volumes.size (%s) does not match target (%s) after grow" + % (db_size, target_size)) + + # ONTAP: LUN space.size must reflect the new allocation + self.assertEqual( + self._data_lun_size(), target_size, + "ONTAP LUN space.size does not match the requested size") + self.assertEqual( + len(self._lun_maps()), 0, + "The data LUN should remain unmapped while the VM is stopped") + + # ------------------------------------------------------------------ + # Step 08 — Shrink must fail + # ------------------------------------------------------------------ + + @attr(tags=["iscsi_vm_workflow"], required_hardware=True) + def test_08_shrink_rejected(self): + """ + Attempt to shrink the grown iSCSI volume back to its initial size. + Verifies: + - The ONTAP driver rejects shrink even with shrinkok=true + - CloudStack and ONTAP LUN sizes remain at the grown value + """ + if self.__class__.vm is None: + self.skipTest("VM not deployed — test_03 was skipped") + self.assertIsNotNone(self.__class__.volume, + "Volume absent — test_02 must pass first") + self.assertEqual( + self._get_db_volume_size(self.__class__.volume.id), + self.__class__.target_volume_size, + "Volume was not grown to the target size — test_07 must pass " + "before the shrink check") + + initial_gib = self.__class__.initial_volume_size // (1024 ** 3) + with self.assertRaises(Exception) as context: + self._resize_volume( + self.__class__.volume.id, initial_gib, shrink_ok=True) + self.assertIn( + "does not support shrinking", str(context.exception), + "Shrink failed for an unexpected reason") + + volume = self._poll_volume_size( + self.__class__.volume.id, self.__class__.target_volume_size) + self.assertIsNotNone( + volume, "CloudStack volume size changed after rejected shrink") + + # DB: size must remain at grown value after rejected shrink + db_size = self._get_db_volume_size(self.__class__.volume.id) + self.assertEqual( + db_size, self.__class__.target_volume_size, + "cloud DB volumes.size did not remain at the grown target after " + "the rejected iSCSI shrink") + + self.assertEqual( + self._data_lun_size(), self.__class__.target_volume_size, + "ONTAP LUN size changed after rejected shrink") + + # ------------------------------------------------------------------ + # Step 09 — Start VM — LUN-maps should be re-created + # ------------------------------------------------------------------ + + @attr(tags=["iscsi_vm_workflow"], required_hardware=True) + def test_09_start_vm_lun_remapped(self): """ Start the stopped VM. Covers TDS VM Start (iSCSI): 'luns should be re-mapped again to @@ -667,11 +852,11 @@ def test_06_start_vm_lun_remapped(self): ) # ------------------------------------------------------------------ - # Step 07 — Detach volume from running VM + # Step 10 — Detach volume from running VM # ------------------------------------------------------------------ @attr(tags=["iscsi_vm_workflow"], required_hardware=True) - def test_07_detach_volume_from_vm(self): + def test_10_detach_volume_from_vm(self): """ Detach the iSCSI data volume from the running VM. Verifies: @@ -730,11 +915,34 @@ def test_07_detach_volume_from_vm(self): ) # ------------------------------------------------------------------ - # Step 08 — Destroy VM and clean up pool + # Step 11 — Grow detached data volume + # ------------------------------------------------------------------ + + @attr(tags=["iscsi_vm_workflow"], required_hardware=True) + def test_11_grow_detached_volume(self): + """Grow the materialized LUN after it has been detached.""" + self.assertIsNotNone(self.__class__.volume, + "Volume absent — test_02 must pass first") + detached_target = self.__class__.target_volume_size + (1024 ** 3) + self._resize_volume( + self.__class__.volume.id, detached_target // (1024 ** 3)) + + self._poll_volume_size(self.__class__.volume.id, detached_target) + self.assertEqual( + self._get_db_volume_size(self.__class__.volume.id), + detached_target, + "DB size does not match detached iSCSI grow target") + self.assertEqual( + self._data_lun_size(), detached_target, + "ONTAP LUN size does not match detached grow target") + self.__class__.target_volume_size = detached_target + + # ------------------------------------------------------------------ + # Step 12 — Destroy VM and clean up pool # ------------------------------------------------------------------ @attr(tags=["iscsi_vm_workflow"], required_hardware=True) - def test_08_destroy_vm_and_cleanup(self): + def test_12_destroy_vm_and_cleanup(self): """ Destroy the VM (with expunge), delete the data volume, force-delete the ONTAP pool, and delete the guest network created for this suite. diff --git a/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py b/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py index 48158c682bd0..a035340bef25 100644 --- a/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py +++ b/test/integration/plugins/ontap/nfs3/instance/test_vm_volume_attach.py @@ -19,7 +19,7 @@ Sequential workflow integration tests for NetApp ONTAP data volume lifecycle with a running virtual machine. -Tests are numbered test_01 ... test_08 and must run in that order. Each step +Tests are numbered test_01 ... test_12 and must run in that order. Each step builds on the shared state established by the previous step. Workflow: @@ -27,10 +27,14 @@ 02 Create a CloudStack data volume on the ONTAP pool 03 Deploy a VM (template and service offering discovered at setup time) 04 Attach the ONTAP data volume to the running VM - 05 Stop the VM — export policy stays; volume remains attached in CS - 06 Start the VM — VM Running; volume still attached; FlexVol online - 07 Detach the ONTAP data volume from the VM - 08 Destroy VM; delete ONTAP volume; enter maintenance; delete pool + 05 Verify resize is rejected while the attached VM is running + 06 Stop the VM — export policy stays; volume remains attached in CS + 07 Grow the attached volume and its qcow2 file while the VM is stopped + 08 Verify shrinking the volume is rejected without changing its size + 09 Start the VM — VM Running; volume still attached; FlexVol online + 10 Detach the ONTAP data volume from the VM + 11 Grow the detached data volume + 12 Destroy VM; delete ONTAP volume; enter maintenance; delete pool Prerequisites: - CloudStack management server with the NetApp ONTAP plugin deployed @@ -157,6 +161,9 @@ class TestOntapVMVolumeAttach(OntapTestBase): service_offering_id = None network_id = None # None for Basic zones _created_network_id = None # network created by this suite for Advanced zones + initial_volume_size = None + target_volume_size = None + volume_path = None _vol_name_prefix = "OntapVMVol" @@ -348,8 +355,15 @@ def _poll_vm_state(self, vm_id, target_state, timeout=300, interval=10): deadline = time.time() + timeout current_state = "unknown" while time.time() < deadline: - vms = self.apiClient.listVirtualMachines( - _list_vms_cmd(vm_id)) + try: + vms = self.apiClient.listVirtualMachines( + _list_vms_cmd(vm_id)) + except Exception as exc: + logger.warning( + "Transient API error while waiting for VM %s to reach " + "'%s': %s", vm_id, target_state, exc) + time.sleep(interval) + continue if vms: current_state = vms[0].state if current_state.lower() == target_state.lower(): @@ -552,12 +566,70 @@ def test_04_attach_volume_to_vm(self): "after attach; files present: %s" % (vol.id, pool.name, files) ) + volumes = self.apiClient.listVolumes(_list_vols_cmd(vol.id)) or [] + self.assertTrue(volumes, "Attached volume is not listed in CloudStack") + initial_size = int(volumes[0].size) + self.__class__.initial_volume_size = initial_size + self.__class__.target_volume_size = ( + (initial_size // (1024 ** 3)) + 1 + ) * (1024 ** 3) + self.__class__.volume_path = vol_file + + backend_size = self._get_qcow2_virtual_size(pool.id, vol_file) + self.assertEqual( + backend_size, initial_size, + "Initial qcow2 virtual size should match the CloudStack volume size" + ) + + # ------------------------------------------------------------------ + # Step 05 - Resize attached volume while VM is running must fail + # ------------------------------------------------------------------ + + @attr(tags=["vm_volume_workflow"], required_hardware=True) + def test_05_resize_running_vm_rejected(self): + """ + Attempt to grow the attached volume while its managed KVM VM is + running. + Verifies: + - CloudStack rejects the resize + - CloudStack volume size is unchanged + - qcow2 virtual size is unchanged + """ + if self.__class__.vm is None: + self.skipTest("VM not deployed — test_03 was skipped") + self.assertIsNotNone(self.__class__.volume, + "Volume absent — test_02 must pass first") + + target_gib = self.__class__.target_volume_size // (1024 ** 3) + with self.assertRaises(Exception) as context: + self._resize_volume(self.__class__.volume.id, target_gib) + self.assertIn( + "not in the Stopped state", str(context.exception), + "Resize failed for an unexpected reason") + + volume = self._poll_volume_size( + self.__class__.volume.id, self.__class__.initial_volume_size) + self.assertIsNotNone( + volume, "CloudStack volume size changed after rejected resize") + + # DB: size must remain unchanged after a rejected resize + db_size = self._get_db_volume_size(self.__class__.volume.id) + self.assertEqual( + db_size, self.__class__.initial_volume_size, + "cloud DB volumes.size changed after rejected running-VM resize") + + backend_size = self._get_qcow2_virtual_size( + self.__class__.pool.id, self.__class__.volume_path) + self.assertEqual( + backend_size, self.__class__.initial_volume_size, + "qcow2 virtual size changed after rejected running-VM resize") + # ------------------------------------------------------------------ - # Step 05 - Stop VM — export policy must be retained + # Step 06 - Stop VM — export policy must be retained # ------------------------------------------------------------------ @attr(tags=["vm_volume_workflow"], required_hardware=True) - def test_05_stop_vm_export_retained(self): + def test_06_stop_vm_export_retained(self): """ Stop the running VM while the NFS3 data volume is still attached. Unlike iSCSI (where LUN-maps are removed on VM stop), NFS3 export @@ -578,7 +650,15 @@ def test_05_stop_vm_export_retained(self): cmd = stopVirtualMachineAPI.stopVirtualMachineCmd() cmd.id = vm.id - self.apiClient.stopVirtualMachine(cmd) + try: + self.apiClient.stopVirtualMachine(cmd) + except Exception as exc: + # Marvin can lose the management API connection while polling an + # already-submitted async stop job. The VM state is authoritative, + # so continue polling before treating the stop as failed. + logger.warning( + "stopVirtualMachine polling failed; checking VM state: %s", + exc) result = self._poll_vm_state(vm.id, "Stopped", timeout=300) self.assertEqual( @@ -611,11 +691,109 @@ def test_05_stop_vm_export_retained(self): ) # ------------------------------------------------------------------ - # Step 06 - Start VM — volume accessible; FlexVol online + # Step 07 - Grow attached volume while VM is stopped # ------------------------------------------------------------------ @attr(tags=["vm_volume_workflow"], required_hardware=True) - def test_06_start_vm_volume_accessible(self): + def test_07_grow_stopped_vm_volume(self): + """ + Grow the attached NFS3 volume while its managed KVM VM is stopped. + Verifies: + - CloudStack reports the target size and Ready state + - KVM reports the target qcow2 virtual size + - The backing file remains present in the ONTAP FlexVol + """ + if self.__class__.vm is None: + self.skipTest("VM not deployed — test_03 was skipped") + self.assertIsNotNone(self.__class__.volume, + "Volume absent — test_02 must pass first") + + target_size = self.__class__.target_volume_size + self._resize_volume( + self.__class__.volume.id, target_size // (1024 ** 3)) + + volume = self._poll_volume_size( + self.__class__.volume.id, target_size) + self.assertIsNotNone( + volume, "CloudStack did not report the grown volume size") + + # KVM: qcow2 virtual size must reflect the new allocation + backend_size = self._get_qcow2_virtual_size( + self.__class__.pool.id, self.__class__.volume_path) + self.assertEqual( + backend_size, target_size, + "KVM qcow2 virtual size does not match the requested size") + + # DB: cloud.volumes.size must be updated by the driver's volumeDao call + db_size = self._get_db_volume_size(self.__class__.volume.id) + self.assertEqual( + db_size, target_size, + "cloud DB volumes.size (%s) does not match target (%s) after grow" + % (db_size, target_size)) + + # ONTAP REST: backing file must still be present in the FlexVol. + # Note: ONTAP's file-API returns bytes_used (actual disk blocks), NOT + # the qcow2 virtual size, so the host-side qemu-img check above is the + # correct NFS3 backend verification for virtual size. + files = self.ontap.list_files_in_volume(self.__class__.pool.name) + self.assertIn( + self.__class__.volume_path, files, + "NFS3 backing file disappeared from ONTAP after resize") + + # ------------------------------------------------------------------ + # Step 08 - Shrink must fail + # ------------------------------------------------------------------ + + @attr(tags=["vm_volume_workflow"], required_hardware=True) + def test_08_shrink_rejected(self): + """ + Attempt to shrink the grown NFS3 volume back to its initial size. + Verifies: + - CloudStack rejects qcow2 shrink even with shrinkok=true + - CloudStack and qcow2 sizes remain at the grown value + """ + if self.__class__.vm is None: + self.skipTest("VM not deployed — test_03 was skipped") + self.assertIsNotNone(self.__class__.volume, + "Volume absent — test_02 must pass first") + self.assertEqual( + self._get_db_volume_size(self.__class__.volume.id), + self.__class__.target_volume_size, + "Volume was not grown to the target size — test_07 must pass " + "before the shrink check") + + initial_gib = self.__class__.initial_volume_size // (1024 ** 3) + with self.assertRaises(Exception) as context: + self._resize_volume( + self.__class__.volume.id, initial_gib, shrink_ok=True) + self.assertIn( + "Unable to shrink volumes of type QCOW2", str(context.exception), + "Shrink failed for an unexpected reason") + + volume = self._poll_volume_size( + self.__class__.volume.id, self.__class__.target_volume_size) + self.assertIsNotNone( + volume, "CloudStack volume size changed after rejected shrink") + + # DB: size must remain at grown value after rejected shrink + db_size = self._get_db_volume_size(self.__class__.volume.id) + self.assertEqual( + db_size, self.__class__.target_volume_size, + "cloud DB volumes.size did not remain at the grown target after " + "the rejected NFS3 shrink") + + backend_size = self._get_qcow2_virtual_size( + self.__class__.pool.id, self.__class__.volume_path) + self.assertEqual( + backend_size, self.__class__.target_volume_size, + "qcow2 virtual size changed after rejected shrink") + + # ------------------------------------------------------------------ + # Step 09 - Start VM — volume accessible; FlexVol online + # ------------------------------------------------------------------ + + @attr(tags=["vm_volume_workflow"], required_hardware=True) + def test_09_start_vm_volume_accessible(self): """ Start the stopped VM. Verifies: @@ -666,12 +844,20 @@ def test_06_start_vm_volume_accessible(self): "got virtualmachineid=%s" % (vm.id, vol_vmid) ) + instance_name = getattr(result, "instancename", None) + self.assertIsNotNone( + instance_name, "Running VM response did not include instancename") + self.assertEqual( + self._get_vm_data_disk_capacity(instance_name), + self.__class__.target_volume_size, + "KVM data-disk capacity does not reflect the NFS3 resize") + # ------------------------------------------------------------------ - # Step 07 - Detach ONTAP data volume from the VM + # Step 10 - Detach ONTAP data volume from the VM # ------------------------------------------------------------------ @attr(tags=["vm_volume_workflow"], required_hardware=True) - def test_07_detach_volume_from_vm(self): + def test_10_detach_volume_from_vm(self): """ Detach the ONTAP data volume from the running VM. Verifies: @@ -754,11 +940,36 @@ def test_07_detach_volume_from_vm(self): ) # ------------------------------------------------------------------ - # Step 08 - Destroy VM, delete volume, delete pool + # Step 11 - Grow detached data volume + # ------------------------------------------------------------------ + + @attr(tags=["vm_volume_workflow"], required_hardware=True) + def test_11_grow_detached_volume(self): + """Grow the materialized qcow2 after it has been detached.""" + self.assertIsNotNone(self.__class__.volume, + "Volume absent — test_02 must pass first") + detached_target = self.__class__.target_volume_size + (1024 ** 3) + self._resize_volume( + self.__class__.volume.id, detached_target // (1024 ** 3)) + + self._poll_volume_size(self.__class__.volume.id, detached_target) + self.assertEqual( + self._get_db_volume_size(self.__class__.volume.id), + detached_target, + "DB size does not match detached NFS3 grow target") + self.assertEqual( + self._get_qcow2_virtual_size( + self.__class__.pool.id, self.__class__.volume_path), + detached_target, + "qcow2 virtual size does not match detached grow target") + self.__class__.target_volume_size = detached_target + + # ------------------------------------------------------------------ + # Step 12 - Destroy VM, delete volume, delete pool # ------------------------------------------------------------------ @attr(tags=["vm_volume_workflow"], required_hardware=True) - def test_08_destroy_vm_and_cleanup(self): + def test_12_destroy_vm_and_cleanup(self): """ Destroy the VM, delete the ONTAP data volume, enter maintenance, then delete the pool. diff --git a/test/integration/plugins/ontap/ontap_test_base.py b/test/integration/plugins/ontap/ontap_test_base.py index 4f60dbf9433f..ce520df177f1 100644 --- a/test/integration/plugins/ontap/ontap_test_base.py +++ b/test/integration/plugins/ontap/ontap_test_base.py @@ -25,9 +25,11 @@ _poll_pool_state, _create_volume, and _delete_pool """ +import json import logging import random import requests +import shlex import sys import time import urllib3 @@ -38,7 +40,8 @@ createVolume as createVolumeAPI, deleteStoragePool as deleteStoragePoolAPI, deleteVolume as deleteVolumeAPI, - listDiskOfferings as listDiskOfferingsAPI, + listVolumes as listVolumesAPI, + resizeVolume as resizeVolumeAPI, updateStoragePool as updateStoragePoolAPI, ) from marvin.cloudstackAPI import listHosts as listHostsAPI @@ -219,7 +222,7 @@ def get_lun(self, svm_name, lun_path): """Return the ONTAP LUN record for the given full path, or None.""" data = self._get("/storage/luns", params={"svm.name": svm_name, "name": lun_path, - "fields": "name,uuid,enabled,status"}) + "fields": "name,uuid,enabled,status,space.size"}) records = data.get("records", []) return records[0] if records else None @@ -228,7 +231,7 @@ def list_luns_in_volume(self, svm_name, vol_name): prefix = "/vol/%s/" % vol_name data = self._get("/storage/luns", params={"svm.name": svm_name, - "fields": "name,uuid,enabled,status"}) + "fields": "name,uuid,enabled,status,space.size"}) return [r for r in data.get("records", []) if r.get("name", "").startswith(prefix)] @@ -359,19 +362,21 @@ def _setup_cloudstack_resources(cls, config, account_testdata): list_hosts_cmd.type = "Routing" cls.cluster_hosts = cls.apiClient.listHosts(list_hosts_cmd) or [] - list_do_cmd = listDiskOfferingsAPI.listDiskOfferingsCmd() - list_do_cmd.domainid = cls.domain.id - offerings = cls.apiClient.listDiskOfferings(list_do_cmd) - if offerings: - cls.disk_offering_id = offerings[0].id - else: - # No disk offerings exist yet — create a minimal one for tests - do = DiskOffering.create( - cls.apiClient, - {"name": "ontap-test-do", "displaytext": "ONTAP test disk offering", "disksize": 2}, - ) - cls._cleanup.append(do) - cls.disk_offering_id = do.id + # Use a deterministic, non-strict 2 GiB offering. Selecting the first + # offering in the environment can choose a fixed-size or very large + # offering that cannot exercise the resize API on the small test pool. + offering_name = "ontap-test-do-%d" % random.randint(0, 99999) + do = DiskOffering.create( + cls.apiClient, + { + "name": offering_name, + "displaytext": "ONTAP test disk offering", + "disksize": 2, + "storagetype": "shared", + }, + ) + cls._cleanup.append(do) + cls.disk_offering_id = do.id # Parse KVM host SSH credentials from zones/pods/clusters/hosts config. # Used by _cleanup_kvm_storage_pool_mounts to unmount stale NFS pools. @@ -612,6 +617,132 @@ def _create_volume(self, pool_id): cmd.domainid = self.domain.id return self.apiClient.createVolume(cmd) + def _resize_volume(self, volume_id, size_gib, shrink_ok=False): + """Resize a volume to ``size_gib`` through the CloudStack API.""" + cmd = resizeVolumeAPI.resizeVolumeCmd() + cmd.id = volume_id + cmd.size = size_gib + if shrink_ok: + cmd.shrinkok = True + return self.apiClient.resizeVolume(cmd) + + def _poll_volume_size(self, volume_id, expected_bytes, timeout=180, + interval=5): + """Poll until a volume is Ready with the expected size in bytes.""" + deadline = time.time() + timeout + last_size = None + last_state = "not listed" + while time.time() < deadline: + cmd = listVolumesAPI.listVolumesCmd() + cmd.id = volume_id + cmd.listall = True + volumes = self.apiClient.listVolumes(cmd) or [] + if volumes: + size = int(getattr(volumes[0], "size", 0) or 0) + state = getattr(volumes[0], "state", "") + last_size = size + last_state = state + if size == expected_bytes and state == "Ready": + return volumes[0] + time.sleep(interval) + self.fail( + "Volume %s did not reach Ready with size %s within %ds " + "(last size=%s, state=%s)" + % (volume_id, expected_bytes, timeout, last_size, last_state) + ) + + def _get_qcow2_virtual_size(self, pool_uuid, volume_path): + """Read a qcow2 virtual size from the KVM host mounting the NFS pool.""" + pool_arg = shlex.quote(str(pool_uuid)) + volume_arg = shlex.quote(str(volume_path)) + command = ( + "path=$(virsh vol-path --pool {pool} {volume} 2>/dev/null || true); " + "if [ -z \"$path\" ]; then path=/mnt/{pool}/{volume}; fi; " + "qemu_img=$(command -v cloud-qemu-img || command -v qemu-img); " + "test -n \"$qemu_img\" || " + "{{ echo 'qemu-img executable not found' >&2; exit 1; }}; " + "\"$qemu_img\" info -U --output=json \"$path\"" + ).format(pool=pool_arg, volume=volume_arg) + + errors = [] + for creds in self.kvm_hosts_ssh_creds: + try: + ssh = SshClient( + creds["host"], 22, creds["user"], creds["password"], + retries=3, delay=3, timeout=15.0, + ) + output = "\n".join(ssh.execute(command)) + json_start = output.find("{") + if json_start < 0: + raise ValueError(output) + info, _ = json.JSONDecoder().raw_decode(output[json_start:]) + return int(info["virtual-size"]) + except Exception as exc: + errors.append("%s: %s" % (creds.get("host", "?"), exc)) + + raise RuntimeError( + "Could not read qcow2 virtual size from any KVM host: %s" + % "; ".join(errors) + ) + + def _get_vm_data_disk_capacity(self, instance_name): + """Return the libvirt capacity of the VM's first data disk in bytes. + + The ONTAP VM workflow attaches exactly one data disk. The first libvirt + disk is therefore the root disk and the second is the resized data + disk, independent of whether its source is NFS or iSCSI multipath. + """ + instance_arg = shlex.quote(str(instance_name)) + command = ( + "target=$(virsh domblklist {vm} --details | " + "awk '$2 == \"disk\" {{ count++; if (count == 2) " + "{{ print $3; exit }} }}'); " + "test -n \"$target\" || " + "{{ echo 'data disk target not found' >&2; exit 1; }}; " + "virsh domblkinfo {vm} \"$target\" | " + "awk '$1 == \"Capacity:\" {{ print $2; exit }}'" + ).format(vm=instance_arg) + + errors = [] + for creds in self.kvm_hosts_ssh_creds: + try: + ssh = SshClient( + creds["host"], 22, creds["user"], creds["password"], + retries=3, delay=3, timeout=15.0, + ) + output = "\n".join(ssh.execute(command)).strip() + if not output: + raise ValueError("virsh returned no data-disk capacity") + return int(output.splitlines()[-1].strip()) + except Exception as exc: + errors.append("%s: %s" % (creds.get("host", "?"), exc)) + + raise RuntimeError( + "Could not read VM data-disk capacity from any KVM host: %s" + % "; ".join(errors) + ) + + def _get_db_volume_size(self, volume_uuid): + """Query the cloud DB directly for the size (bytes) stored in the + ``volumes`` table for *volume_uuid*. + + Uses the per-suite ``dbConnection`` set in ``setUpClass`` (consistent + with the pattern in all other ONTAP test suites). + + Returns the integer size, or None if the row is not found. + This validates that the ONTAP driver's ``volumeDao.update()`` call + persisted the new size to the database — independently of the + CloudStack API layer. + """ + db = self.__class__.dbConnection + rows = db.execute( + "SELECT size FROM volumes WHERE uuid = %s AND removed IS NULL", + params=(volume_uuid,), + ) + if not rows: + return None + return int(rows[0][0]) + def _delete_pool(self, pool_id, forced=False): """Issue deleteStoragePool for the given pool id.""" cmd = deleteStoragePoolAPI.deleteStoragePoolCmd()