1# (c) 2020, NetApp, Inc 2# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 3 4''' unit test template for ONTAP Ansible module ''' 5 6from __future__ import (absolute_import, division, print_function) 7__metaclass__ = type 8 9import json 10import pytest 11 12from ansible.module_utils import basic 13from ansible.module_utils._text import to_bytes 14from ansible_collections.netapp.ontap.tests.unit.compat import unittest 15from ansible_collections.netapp.ontap.tests.unit.compat.mock import patch, Mock 16import ansible_collections.netapp.ontap.plugins.module_utils.netapp as netapp_utils 17 18from ansible_collections.netapp.ontap.plugins.modules.na_ontap_volume \ 19 import NetAppOntapVolume as volume_module # module under test 20 21# needed for get and modify/delete as they still use ZAPI 22if not netapp_utils.has_netapp_lib(): 23 pytestmark = pytest.mark.skip('skipping as missing required netapp_lib') 24 25# REST API canned responses when mocking send_request 26SRR = { 27 # common responses 28 'is_rest': (200, dict(version=dict(generation=9, major=8, minor=0, full='dummy')), None), 29 'is_zapi': (400, {}, "Unreachable"), 30 'empty_good': (200, {}, None), 31 'no_record': (200, {'num_records': 0, 'records': []}, None), 32 'end_of_sequence': (500, None, "Unexpected call to send_request"), 33 'generic_error': (400, None, "Expected error"), 34 # module specific responses 35 'svm_record': (200, 36 {'records': [{"uuid": "09e9fd5e-8ebd-11e9-b162-005056b39fe7", 37 "name": "test_svm", 38 "subtype": "default", 39 "language": "c.utf_8", 40 "aggregates": [{"name": "aggr_1", 41 "uuid": "850dd65b-8811-4611-ac8c-6f6240475ff9"}, 42 {"name": "aggr_2", 43 "uuid": "850dd65b-8811-4611-ac8c-6f6240475ff9"}], 44 "comment": "new comment", 45 "ipspace": {"name": "ansible_ipspace", 46 "uuid": "2b760d31-8dfd-11e9-b162-005056b39fe7"}, 47 "snapshot_policy": {"uuid": "3b611707-8dfd-11e9-b162-005056b39fe7", 48 "name": "old_snapshot_policy"}, 49 "nfs": {"enabled": True}, 50 "cifs": {"enabled": False}, 51 "iscsi": {"enabled": False}, 52 "fcp": {"enabled": False}, 53 "nvme": {"enabled": False}}]}, None), 54 # module specific responses 55 'nas_app_record': (200, 56 {'records': [{"uuid": "09e9fd5e-8ebd-11e9-b162-005056b39fe7", 57 "name": "test_app", 58 "nas": { 59 "application_components": [{'xxx': 1}]} 60 }]}, None) 61} 62 63 64def set_module_args(args): 65 """prepare arguments so that they will be picked up during module creation""" 66 args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) 67 basic._ANSIBLE_ARGS = to_bytes(args) # pylint: disable=protected-access 68 69 70class AnsibleExitJson(Exception): 71 """Exception class to be raised by module.exit_json and caught by the test case""" 72 73 74class AnsibleFailJson(Exception): 75 """Exception class to be raised by module.fail_json and caught by the test case""" 76 77 78def exit_json(*args, **kwargs): # pylint: disable=unused-argument 79 """function to patch over exit_json; package return data into an exception""" 80 if 'changed' not in kwargs: 81 kwargs['changed'] = False 82 raise AnsibleExitJson(kwargs) 83 84 85def fail_json(*args, **kwargs): # pylint: disable=unused-argument 86 """function to patch over fail_json; package return data into an exception""" 87 kwargs['failed'] = True 88 raise AnsibleFailJson(kwargs) 89 90 91class MockONTAPConnection(object): 92 ''' mock server connection to ONTAP host ''' 93 94 def __init__(self, kind=None, data=None, get_volume=None): 95 ''' save arguments ''' 96 self.type = kind 97 self.params = data 98 self.xml_in = None 99 self.xml_out = None 100 self.get_volume = get_volume 101 self.zapis = list() 102 103 def invoke_successfully(self, xml, enable_tunneling): # pylint: disable=unused-argument 104 ''' mock invoke_successfully returning xml data ''' 105 self.xml_in = xml 106 zapi = xml.get_name() 107 self.zapis.append(zapi) 108 request = xml.to_string().decode('utf-8') 109 if self.type == 'error': 110 raise OSError('unexpected call to %s' % self.params) 111 print('request:', request) 112 if request.startswith('<volume-get-iter>'): 113 what = None 114 if self.get_volume: 115 what = self.get_volume.pop(0) 116 if what is None: 117 xml = self.build_empty_response() 118 else: 119 xml = self.build_get_response(what) 120 self.xml_out = xml 121 print('response:', xml.to_string()) 122 return xml 123 124 @staticmethod 125 def build_response(data): 126 ''' build xml data for vserser-info ''' 127 xml = netapp_utils.zapi.NaElement('xml') 128 xml.translate_struct(data) 129 return xml 130 131 def build_empty_response(self): 132 data = {'num-records': '0'} 133 return self.build_response(data) 134 135 def build_get_response(self, name): 136 ''' build xml data for vserser-info ''' 137 if name is None: 138 return self.build_empty_response() 139 data = {'num-records': 1, 140 'attributes-list': [{ 141 'volume-attributes': { 142 'volume-id-attributes': { 143 'name': name, 144 'instance-uuid': '123', 145 'junction-path': 'jpath', 146 'style-extended': 'flexvol' 147 }, 148 'volume-performance-attributes': { 149 'is-atime-update-enabled': 'true' 150 }, 151 'volume-security-attributes': { 152 'volume-security-unix-attributes': { 153 'permissions': 777 154 } 155 }, 156 'volume-snapshot-attributes': { 157 'snapshot-policy': 'default' 158 }, 159 'volume-snapshot-autodelete-attributes': { 160 'is-autodelete-enabled': 'true' 161 }, 162 'volume-space-attributes': { 163 'size': 10737418240 # 10 GB 164 }, 165 'volume-state-attributes': { 166 'state': 'online' 167 }, 168 } 169 }]} 170 return self.build_response(data) 171 172 173class TestMyModule(unittest.TestCase): 174 ''' a group of related Unit Tests ''' 175 176 def setUp(self): 177 self.mock_module_helper = patch.multiple(basic.AnsibleModule, 178 exit_json=exit_json, 179 fail_json=fail_json) 180 self.mock_module_helper.start() 181 self.addCleanup(self.mock_module_helper.stop) 182 # self.server = MockONTAPConnection() 183 self.mock_vserver = { 184 'name': 'test_svm', 185 'root_volume': 'ansible_vol', 186 'root_volume_aggregate': 'ansible_aggr', 187 'aggr_list': 'aggr_1,aggr_2', 188 'ipspace': 'ansible_ipspace', 189 'subtype': 'default', 190 'language': 'c.utf_8', 191 'snapshot_policy': 'old_snapshot_policy', 192 'comment': 'new comment' 193 } 194 195 @staticmethod 196 def mock_args(): 197 return {'name': 'test_volume', 198 'vserver': 'ansibleSVM', 199 'nas_application_template': dict( 200 tiering=None 201 ), 202 # 'aggregate_name': 'whatever', # not used for create when using REST application/applications 203 'size': 10, 204 'size_unit': 'gb', 205 'hostname': 'test', 206 'username': 'test_user', 207 'password': 'test_pass!'} 208 209 def get_volume_mock_object(self, **kwargs): 210 volume_obj = volume_module() 211 netapp_utils.ems_log_event = Mock(return_value=None) 212 volume_obj.server = MockONTAPConnection(**kwargs) 213 volume_obj.cluster = MockONTAPConnection(kind='error', data='cluster ZAPI.') 214 return volume_obj 215 216 def test_module_fail_when_required_args_missing(self): 217 ''' required arguments are reported as errors ''' 218 with pytest.raises(AnsibleFailJson) as exc: 219 set_module_args({}) 220 volume_module() 221 print('Info: %s' % exc.value.args[0]['msg']) 222 223 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 224 def test_fail_if_aggr_is_set(self, mock_request): 225 data = dict(self.mock_args()) 226 data['aggregate_name'] = 'should_fail' 227 set_module_args(data) 228 mock_request.side_effect = [ 229 SRR['is_rest'], 230 SRR['end_of_sequence'] 231 ] 232 with pytest.raises(AnsibleFailJson) as exc: 233 self.get_volume_mock_object().apply() 234 error = 'Conflict: aggregate_name is not supported when application template is enabled. Found: aggregate_name: should_fail' 235 assert exc.value.args[0]['msg'] == error 236 237 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 238 def test_missing_size(self, mock_request): 239 data = dict(self.mock_args()) 240 data.pop('size') 241 set_module_args(data) 242 mock_request.side_effect = [ 243 SRR['is_rest'], 244 SRR['no_record'], # GET application/applications 245 SRR['end_of_sequence'] 246 ] 247 with pytest.raises(AnsibleFailJson) as exc: 248 self.get_volume_mock_object().apply() 249 error = 'Error: "size" is required to create nas application.' 250 assert exc.value.args[0]['msg'] == error 251 252 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 253 def test_mismatched_tiering_policies(self, mock_request): 254 data = dict(self.mock_args()) 255 data['tiering_policy'] = 'none' 256 data['nas_application_template'] = dict( 257 tiering=dict(policy='auto') 258 ) 259 set_module_args(data) 260 mock_request.side_effect = [ 261 SRR['is_rest'], 262 SRR['end_of_sequence'] 263 ] 264 with pytest.raises(AnsibleFailJson) as exc: 265 self.get_volume_mock_object().apply() 266 error = 'Conflict: if tiering_policy and nas_application_template tiering policy are both set, they must match.' 267 error += ' Found "none" and "auto".' 268 assert exc.value.args[0]['msg'] == error 269 270 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 271 def test_rest_error(self, mock_request): 272 data = dict(self.mock_args()) 273 set_module_args(data) 274 mock_request.side_effect = [ 275 SRR['is_rest'], 276 SRR['no_record'], # GET application/applications 277 SRR['generic_error'], # POST application/applications 278 SRR['end_of_sequence'] 279 ] 280 with pytest.raises(AnsibleFailJson) as exc: 281 self.get_volume_mock_object().apply() 282 msg = 'Error in create_nas_application: calling: application/applications: got %s.' % SRR['generic_error'][2] 283 assert exc.value.args[0]['msg'] == msg 284 285 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 286 def test_rest_successfully_created(self, mock_request): 287 data = dict(self.mock_args()) 288 set_module_args(data) 289 mock_request.side_effect = [ 290 SRR['is_rest'], 291 SRR['no_record'], # GET application/applications 292 SRR['empty_good'], # POST application/applications 293 SRR['end_of_sequence'] 294 ] 295 with pytest.raises(AnsibleExitJson) as exc: 296 self.get_volume_mock_object().apply() 297 assert exc.value.args[0]['changed'] 298 299 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 300 def test_rest_create_idempotency(self, mock_request): 301 data = dict(self.mock_args()) 302 set_module_args(data) 303 mock_request.side_effect = [ 304 SRR['is_rest'], 305 SRR['no_record'], # GET application/applications 306 SRR['end_of_sequence'] 307 ] 308 with pytest.raises(AnsibleExitJson) as exc: 309 self.get_volume_mock_object(get_volume=['test']).apply() 310 assert not exc.value.args[0]['changed'] 311 312 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 313 def test_rest_successfully_created_with_modify(self, mock_request): 314 ''' since language is not supported in application, the module is expected to: 315 1. create the volume using application REST API 316 2. immediately modify the volume to update options which not available in the nas template. 317 ''' 318 data = dict(self.mock_args()) 319 data['language'] = 'fr' # TODO: apparently language is not supported for modify 320 data['unix_permissions'] = '---rw-rx-r--' 321 set_module_args(data) 322 mock_request.side_effect = [ 323 SRR['is_rest'], 324 SRR['no_record'], # GET application/applications 325 SRR['empty_good'], # POST application/applications 326 SRR['end_of_sequence'] 327 ] 328 my_volume = self.get_volume_mock_object(get_volume=[None, 'test']) 329 with pytest.raises(AnsibleExitJson) as exc: 330 my_volume.apply() 331 assert exc.value.args[0]['changed'] 332 print(exc.value.args[0]) 333 assert 'unix_permissions' in exc.value.args[0]['modify_after_create'] 334 assert 'language' not in exc.value.args[0]['modify_after_create'] # eh! 335 assert 'volume-modify-iter' in my_volume.server.zapis 336 337 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 338 def test_rest_successfully_resized(self, mock_request): 339 ''' make sure resize if using RESP API if sizing_method is present 340 ''' 341 data = dict(self.mock_args()) 342 data['sizing_method'] = 'add_new_resources' 343 data['size'] = 20737418240 344 set_module_args(data) 345 mock_request.side_effect = [ 346 SRR['is_rest'], 347 SRR['no_record'], # GET application/applications 348 SRR['empty_good'], # PATCH application/applications 349 SRR['end_of_sequence'] 350 ] 351 my_volume = self.get_volume_mock_object(get_volume=['test']) 352 with pytest.raises(AnsibleExitJson) as exc: 353 my_volume.apply() 354 assert exc.value.args[0]['changed'] 355 print(exc.value.args[0]) 356 assert 'volume-size' not in my_volume.server.zapis 357 print(mock_request.call_args) 358 query = {'return_timeout': 30, 'sizing_method': 'add_new_resources'} 359 mock_request.assert_called_with('PATCH', 'storage/volumes/123', query, json={'size': 22266633286068469760}) 360 361 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 362 def test_rest_successfully_deleted(self, mock_request): 363 ''' delete volume using REST - no app 364 ''' 365 data = dict(self.mock_args()) 366 data['state'] = 'absent' 367 set_module_args(data) 368 mock_request.side_effect = [ 369 SRR['is_rest'], 370 SRR['no_record'], # GET application/applications 371 SRR['empty_good'], # PATCH storage/volumes - unmount 372 SRR['empty_good'], # DELETE storage/volumes 373 SRR['end_of_sequence'] 374 ] 375 my_volume = self.get_volume_mock_object(get_volume=['test']) 376 with pytest.raises(AnsibleExitJson) as exc: 377 my_volume.apply() 378 assert exc.value.args[0]['changed'] 379 print(exc.value.args[0]) 380 assert 'volume-size' not in my_volume.server.zapis 381 print(mock_request.call_args) 382 print(mock_request.mock_calls) 383 query = {'return_timeout': 30} 384 mock_request.assert_called_with('DELETE', 'storage/volumes/123', query, json=None) 385 386 @patch('ansible_collections.netapp.ontap.plugins.module_utils.netapp.OntapRestAPI.send_request') 387 def test_rest_successfully_deleted_with_app(self, mock_request): 388 ''' delete app 389 ''' 390 data = dict(self.mock_args()) 391 data['state'] = 'absent' 392 set_module_args(data) 393 mock_request.side_effect = [ 394 SRR['is_rest'], 395 SRR['nas_app_record'], # GET application/applications 396 SRR['nas_app_record'], # GET application/applications/uuid 397 SRR['empty_good'], # PATCH storage/volumes - unmount 398 SRR['empty_good'], # DELETE storage/volumes 399 SRR['end_of_sequence'] 400 ] 401 my_volume = self.get_volume_mock_object(get_volume=['test']) 402 with pytest.raises(AnsibleExitJson) as exc: 403 my_volume.apply() 404 assert exc.value.args[0]['changed'] 405 print(exc.value.args[0]) 406 assert 'volume-size' not in my_volume.server.zapis 407 print(mock_request.call_args) 408 print(mock_request.mock_calls) 409 query = {'return_timeout': 30} 410 mock_request.assert_called_with('DELETE', 'storage/volumes/123', query, json=None) 411