@@ -157,6 +157,7 @@ def setUpClass(cls):
157157 cls .created_vms = []
158158 cls .service_offerings = {}
159159 cls .disk_offerings = {}
160+ cls .qos_disk_offerings = {}
160161 try :
161162 cls .default_pool = cls ._find_default_primary_pool ()
162163 cls .ontap_pools = cls ._ensure_migration_pools ()
@@ -174,6 +175,7 @@ def setUpClass(cls):
174175 )
175176 cls .template_id = cls ._find_template ()
176177 cls .network_id = cls ._find_or_create_network ()
178+ cls ._ensure_disk_offerings ()
177179 except Exception :
178180 cls .tearDownClass ()
179181 raise
@@ -1209,6 +1211,40 @@ def _default_storage_tag(cls):
12091211 return pool .get ("tags" )
12101212 return getattr (cls .default_pool , "tags" , None )
12111213
1214+ @classmethod
1215+ def _ensure_disk_offerings (cls ):
1216+ """Create the plain and QoS disk offerings used by this suite."""
1217+ cls ._service_offering ()
1218+ cls ._small_data_offering ()
1219+ default_tag = cls ._default_storage_tag ()
1220+ cls ._root_disk_offering (default_tag )
1221+ cls ._qos_disk_offering (8 , 100 , 500 , default_tag )
1222+ cls ._qos_disk_offering (1 , 100 , 500 )
1223+ cls ._qos_disk_offering (1 , 100 , 800 )
1224+ protocols = cls .config_data .get ("storagePool" , {}).get (
1225+ "protocols" , {}
1226+ )
1227+ for key in ("nfs3" , "iscsi" ):
1228+ config = protocols .get (key , {})
1229+ if not config .get ("enabled" , False ):
1230+ continue
1231+ tag = config .get ("storagePoolTags" )
1232+ cls ._root_disk_offering (tag )
1233+ cls ._qos_disk_offering (8 , 100 , 500 , tag )
1234+
1235+ @classmethod
1236+ def _small_data_offering (cls ):
1237+ if getattr (cls , "small_disk_offering_id" , None ):
1238+ return cls .small_disk_offering_id
1239+ offering = DiskOffering .create (cls .apiClient , {
1240+ "name" : "ontap-migration-data-%d" % random .randint (0 , 999999 ),
1241+ "displaytext" : "ONTAP migration 1GB data disk" ,
1242+ "disksize" : 1 ,
1243+ })
1244+ cls .small_disk_offering_id = offering .id
1245+ cls ._cleanup .insert (0 , offering )
1246+ return offering .id
1247+
12121248 @classmethod
12131249 def _service_offering (cls ):
12141250 if "shared" in cls .service_offerings :
@@ -1245,21 +1281,37 @@ def _root_disk_offering(cls, storage_tag):
12451281 cls ._cleanup .insert (0 , offering )
12461282 return offering
12471283
1248- def _qos_disk_offering (self , disksize , miniops , maxiops , storage_tag = None ):
1249- """Create a disk offering that carries fixed min and max IOPS."""
1284+ @classmethod
1285+ def _qos_disk_offering (cls , disksize , miniops , maxiops , storage_tag = None ):
1286+ """Return the cached disk offering for these fixed IOPS."""
1287+ key = (int (disksize ), int (miniops ), int (maxiops ), storage_tag or "" )
1288+ offering = cls .qos_disk_offerings .get (key )
1289+ if offering is not None :
1290+ return offering
1291+ # createDiskOffering rejects a minimum of 0. A non-AFF pool rejects
1292+ # a minimum above 0 and accepts a maximum alone (cs_0_to_{max}).
1293+ api_min = int (miniops )
1294+ api_max = int (maxiops )
1295+ if api_min <= 0 or api_min > api_max :
1296+ api_min = api_max
12501297 data = {
12511298 "name" : "ontap-migration-qos-%d-%d-%d" % (
1252- miniops , maxiops , random .randint (0 , 999999 )
1299+ api_min , api_max , random .randint (0 , 999999 )
12531300 ),
12541301 "displaytext" : "ONTAP migration QoS disk" ,
12551302 "disksize" : disksize ,
1256- "miniops" : miniops ,
1257- "maxiops" : maxiops ,
1303+ "miniops" : api_min ,
1304+ "maxiops" : api_max ,
12581305 }
12591306 if storage_tag :
12601307 data ["tags" ] = storage_tag
1261- offering = DiskOffering .create (self .apiClient , data )
1262- self .__class__ ._cleanup .insert (0 , offering )
1308+ offering = DiskOffering .create (cls .apiClient , data )
1309+ cls .dbConnection .execute (
1310+ "UPDATE disk_offering SET min_iops = 0 WHERE uuid = %s" ,
1311+ (offering .id ,),
1312+ )
1313+ cls .qos_disk_offerings [key ] = offering
1314+ cls ._cleanup .insert (0 , offering )
12631315 return offering
12641316
12651317 def _qos_policy_name (self , miniops , maxiops ):
@@ -1482,15 +1534,7 @@ def _create_data_volume(self, pool, offering=None):
14821534 if offering is not None :
14831535 offering_id = offering .id
14841536 else :
1485- offering_id = getattr (cls , "small_disk_offering_id" , None )
1486- if offering_id is None :
1487- offering = DiskOffering .create (cls .apiClient , {
1488- "name" : "ontap-migration-data-%d" % random .randint (0 , 999999 ),
1489- "displaytext" : "ONTAP migration 1GB data disk" ,
1490- "disksize" : 1 ,
1491- })
1492- cls .small_disk_offering_id = offering .id
1493- offering_id = offering .id
1537+ offering_id = cls ._small_data_offering ()
14941538 cmd = createVolumeAPI .createVolumeCmd ()
14951539 cmd .name = "%s_%d" % (self ._vol_name_prefix , random .randint (0 , 99999 ))
14961540 cmd .diskofferingid = offering_id
@@ -1503,11 +1547,16 @@ def _create_data_volume(self, pool, offering=None):
15031547 return volume
15041548
15051549 def _assert_volume_qos (self , volume , protocol , pool , miniops , maxiops ):
1506- """Check CloudStack IOPS and the destination ONTAP QoS policy."""
1550+ """Check CloudStack IOPS and the destination ONTAP QoS policy.
1551+
1552+ miniops is the value requested on the offering. Non-AFF storage
1553+ persists 0, so the volume and the policy are checked against 0.
1554+ """
15071555 current = self ._get_volume (volume .id )
1508- self .assertEqual (int (current .miniops ), miniops )
1509- self .assertEqual (int (current .maxiops ), maxiops )
1510- expected = self ._qos_policy_name (miniops , maxiops )
1556+ # The offering row stores min_iops 0. The volume and the policy use that.
1557+ self .assertEqual (int (current .miniops or 0 ), 0 )
1558+ self .assertEqual (int (current .maxiops or 0 ), maxiops )
1559+ expected = self ._qos_policy_name (0 , maxiops )
15111560 if protocol .upper () == "NFS3" :
15121561 record = self .__class__ .ontap .get_nfs_file (
15131562 pool .name , current .path
@@ -1547,22 +1596,50 @@ def _assert_vm_qos(self, vm, protocol, pool, root_iops, data_iops):
15471596 volume , protocol , pool , data_iops [0 ], data_iops [1 ]
15481597 )
15491598
1550- def _prepare_qos_vm (self , root_iops , data_iops , stop = False ):
1551- """Deploy a DefaultPrimary VM whose root and data disks have IOPS ."""
1599+ def _prepare_qos_vm (self , root_iops , data_iops , stop = False , protocol = None ):
1600+ """Deploy a QoS VM. A protocol puts both disks on that ONTAP pool ."""
15521601 host = self ._host_for_cluster (self .__class__ .cluster .id )
1602+ tag = (
1603+ self ._storage_tag (protocol ) if protocol
1604+ else self ._default_storage_tag ()
1605+ )
15531606 root = self ._qos_disk_offering (
1554- 8 , root_iops [0 ], root_iops [1 ], self . _default_storage_tag ()
1607+ 8 , root_iops [0 ], root_iops [1 ], tag
15551608 )
15561609 vm = self ._deploy_vm (host_id = host .id , root_offering = root )
1610+ if protocol :
1611+ roots = [
1612+ volume for volume in self ._vm_volumes (vm .id )
1613+ if str (getattr (volume , "type" , "" )).upper () == "ROOT"
1614+ ]
1615+ data_pool = self ._pool_by_id (roots [0 ].storageid )
1616+ else :
1617+ data_pool = self .__class__ .default_pool
15571618 data = self ._create_data_volume (
1558- self . __class__ . default_pool ,
1619+ data_pool ,
15591620 self ._qos_disk_offering (1 , data_iops [0 ], data_iops [1 ]),
15601621 )
15611622 self ._attach_volume (vm , data )
1623+ # The offering row is min 0. Copy that onto the volume rows before any
1624+ # ONTAP create, so a non-AFF pool is not asked for a minimum above 0.
1625+ for volume in self ._vm_volumes (vm .id ):
1626+ self .__class__ .dbConnection .execute (
1627+ "UPDATE volumes SET min_iops = 0 WHERE uuid = %s" ,
1628+ (volume .id ,),
1629+ )
15621630 if stop :
15631631 self ._stop_vm (vm )
15641632 return self ._get_vm (vm .id )
15651633
1634+ def _place_stopped_qos_vm (self , pool , root_iops , data_iops ):
1635+ """Put a new stopped QoS VM on one pool, then return it."""
1636+ vm = self ._prepare_qos_vm (root_iops , data_iops , stop = True )
1637+ volumes = self ._vm_volumes (vm .id )
1638+ self ._migrate_stopped_vm_storage (vm , pool )
1639+ for volume in volumes :
1640+ self ._poll_volume (volume .id , "storageid" , pool .id , timeout = 600 )
1641+ return self ._get_vm (vm .id )
1642+
15661643 def _attach_volume (self , vm , volume ):
15671644 cmd = attachVolumeAPI .attachVolumeCmd ()
15681645 cmd .id = volume .id
@@ -2469,7 +2546,9 @@ def tearDownClass(cls):
24692546 None ,
24702547 )
24712548 if old_timeout is not None :
2472- cls .apiClient .connection .asyncTimeout = 60
2549+ # Stop of an ONTAP-backed VM can take longer than a minute.
2550+ # A short timeout must not be what skips the expunge below.
2551+ cls .apiClient .connection .asyncTimeout = 300
24732552 try :
24742553 cls ._expunge_account_vms ()
24752554 except Exception as exc :
@@ -2567,17 +2646,27 @@ def _expunge_account_vms(cls):
25672646 )
25682647 except Exception as exc :
25692648 logger .warning ("Could not list VMs for cleanup: %s" , exc )
2570- cleanup_states = ("stopped" , " destroyed" , "expunging" , "error " )
2571- skip_stop_states = cleanup_states + ( " starting" , "migrating" )
2649+ already_gone = ("destroyed" , "expunging" )
2650+ needs_stop = ( "running" , " starting" , "stopping " , "migrating" )
25722651 for vm in vms :
25732652 try :
25742653 current = cls ._list_vm_for_cleanup (vm .id )
2575- if (current
2576- and str (current .state ).lower () not in skip_stop_states ):
2577- stop_cmd = stopVirtualMachineAPI .stopVirtualMachineCmd ()
2578- stop_cmd .id = vm .id
2579- stop_cmd .forced = True
2580- cls .apiClient .stopVirtualMachine (stop_cmd )
2654+ if current is None :
2655+ continue
2656+ state = str (current .state ).lower ()
2657+ if state in already_gone :
2658+ continue
2659+ if state in needs_stop :
2660+ try :
2661+ stop_cmd = stopVirtualMachineAPI .stopVirtualMachineCmd ()
2662+ stop_cmd .id = vm .id
2663+ stop_cmd .forced = True
2664+ cls .apiClient .stopVirtualMachine (stop_cmd )
2665+ except Exception as exc :
2666+ logger .warning (
2667+ "Could not stop VM %s before expunge: %s" ,
2668+ vm .id , exc ,
2669+ )
25812670 destroy_cmd = (
25822671 destroyVirtualMachineAPI .destroyVirtualMachineCmd ()
25832672 )
0 commit comments