diff --git a/plugins/modules/snapshot.py b/plugins/modules/snapshot.py index c7aee93..0916892 100644 --- a/plugins/modules/snapshot.py +++ b/plugins/modules/snapshot.py @@ -1,5 +1,5 @@ #!/usr/bin/python -# Copyright: (c) 2019-2024, Dell Technologies +# Copyright: (c) 2019-2025, Dell Technologies # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -336,7 +336,9 @@ def validate_expiration_timestamp(self, expiration_timestamp): def get_filesystem_snapshot_details(self, snapshot_name): """Returns details of a filesystem Snapshot""" try: - return self.snapshot_api.get_snapshot_snapshot(snapshot_name) + snapshot_details = self.snapshot_api.get_snapshot_snapshot(snapshot_name) + if snapshot_details: + return snapshot_details.to_dict() except utils.ApiException as e: if str(e.status) == "404": log_msg = "Snapshot {0} status is " \ @@ -352,12 +354,6 @@ def get_filesystem_snapshot_details(self, snapshot_name): LOG.error(error_message) self.module.fail_json(msg=error_message) - except Exception as e: - error_message = "Failed to get details of Snapshot {0} with" \ - " error {1} ".format(snapshot_name, str(e)) - LOG.error(error_message) - self.module.fail_json(msg=error_message) - def get_zone_base_path(self, access_zone): """Returns the base path of the Access Zone.""" try: @@ -399,7 +395,7 @@ def create_filesystem_snapshot(self, snapshot_name, "snapshot creation") if desired_retention and desired_retention.lower() != 'none': - if retention_unit is None: + if retention_unit is None or retention_unit == 'hours': expiration_timestamp = (datetime.utcnow() + timedelta( hours=int(desired_retention)) @@ -414,12 +410,6 @@ def create_filesystem_snapshot(self, snapshot_name, epoch_expiry_time = calendar.timegm( time.strptime(str(expiration_timestamp), '%Y-%m-%d %H:%M:%S.%f')) - elif retention_unit == 'hours': - expiration_timestamp = (datetime.utcnow() + timedelta( - hours=int(desired_retention))) - epoch_expiry_time = calendar.timegm( - time.strptime(str(expiration_timestamp), - '%Y-%m-%d %H:%M:%S.%f')) elif desired_retention and \ desired_retention.lower() == 'none': @@ -456,10 +446,6 @@ def delete_filesystem_snapshot(self, snapshot_name): def rename_filesystem_snapshot(self, snapshot, new_name): """Renames a filesystem snapshot""" - if snapshot is None: - self.module.fail_json(msg="Snapshot not found.") - - snapshot = snapshot.to_dict() if snapshot['snapshots'][0]['name'] == new_name: return False @@ -517,7 +503,7 @@ def check_snapshot_modified(self, snapshot, alias, snapshot_modification_details['is_timestamp_modified'] = False snapshot_modification_details['new_expiration_timestamp_value'] = None - snap_details = snapshot.to_dict() + snap_details = snapshot if effective_path is not None: if self.module.params['path'] and \ @@ -549,7 +535,7 @@ def check_snapshot_modified(self, snapshot, alias, # creation timestamp of the snapshot to the desired retention # specified in the Playbook. if desired_retention and desired_retention.lower() != 'none': - if retention_unit is None: + if retention_unit is None or retention_unit == 'hours': expiration_timestamp = \ datetime.fromtimestamp(snap_creation_timestamp) + \ timedelta(hours=int(desired_retention)) @@ -562,12 +548,6 @@ def check_snapshot_modified(self, snapshot, alias, timedelta(days=int(desired_retention)) expiration_timestamp = \ time.mktime(expiration_timestamp.timetuple()) - elif retention_unit == 'hours': - expiration_timestamp = \ - datetime.fromtimestamp(snap_creation_timestamp) + \ - timedelta(hours=int(desired_retention)) - expiration_timestamp = \ - time.mktime(expiration_timestamp.timetuple()) elif desired_retention and desired_retention.lower() == 'none': expiration_timestamp = None info_message = "The new expiration " \ @@ -610,8 +590,11 @@ def check_snapshot_modified(self, snapshot, alias, # This is the case when expiration timestamp may not be present # in the snapshot details. # Expiration timestamp specified in the playbook is not None. - elif 'expires' not in snap_details['snapshots'][0] \ - and expiration_timestamp is not None: + elif ('expires' not in snap_details['snapshots'][0] + and expiration_timestamp is not None) or \ + ('expires' in snap_details['snapshots'][0] and + snap_details['snapshots'][0]['expires'] is + None and expiration_timestamp is not None): snapshot_modification_details['is_timestamp_modified'] = True snapshot_modification_details[ 'new_expiration_timestamp_value'] = expiration_timestamp @@ -627,13 +610,6 @@ def check_snapshot_modified(self, snapshot, alias, snapshot_modification_details[ 'new_expiration_timestamp_value'] = expiration_timestamp modified = True - elif 'expires' in snap_details['snapshots'][0] and \ - snap_details['snapshots'][0]['expires'] is \ - None and expiration_timestamp is not None: - snapshot_modification_details['is_timestamp_modified'] = True - snapshot_modification_details[ - 'new_expiration_timestamp_value'] = expiration_timestamp - modified = True snapshot_alias = self.get_snapshot_alias(snapshot_name) @@ -660,10 +636,11 @@ def modify_filesystem_snapshot(self, snapshot_name, new_timestamp = \ snapshot_modification_details[ 'new_expiration_timestamp_value'] - snapshot_update_param = self.isi_sdk.SnapshotSnapshot( - expires=int(new_timestamp)) - self.snapshot_api.update_snapshot_snapshot( - snapshot_update_param, snapshot_name) + if new_timestamp is not None: + snapshot_update_param = self.isi_sdk.SnapshotSnapshot( + expires=int(new_timestamp)) + self.snapshot_api.update_snapshot_snapshot( + snapshot_update_param, snapshot_name) changed = True if snapshot_modification_details['is_alias_modified']: new_alias = \ @@ -820,7 +797,7 @@ def perform_module_operation(self): '{0} details'.format(snapshot_name) LOG.info(info_message) result['snapshot_details'] = \ - self.get_filesystem_snapshot_details(snapshot_name).to_dict() + self.get_filesystem_snapshot_details(snapshot_name) # Finally update the module result! self.module.exit_json(**result) diff --git a/plugins/modules/synciqjob.py b/plugins/modules/synciqjob.py index 1b10d95..0a3d12f 100644 --- a/plugins/modules/synciqjob.py +++ b/plugins/modules/synciqjob.py @@ -309,12 +309,6 @@ def get_job_details(self, job_id): (error_obj=e)) LOG.error(error_message) self.module.fail_json(msg=error_message) - except Exception as e: - error_message = 'Get details of SyncIQ job %s failed with ' \ - 'error: %s' % (job_id, utils.determine_error - (error_obj=e)) - LOG.error(error_message) - self.module.fail_json(msg=error_message) def modify_job_state(self, job_id, job_state): """ @@ -334,12 +328,6 @@ def modify_job_state(self, job_id, job_state): (error_obj=e)) LOG.error(error_message) self.module.fail_json(msg=error_message) - except Exception as e: - error_message = 'Modify state of SyncIQ job %s failed with ' \ - 'error: %s' % (job_id, utils.determine_error - (error_obj=e)) - LOG.error(error_message) - self.module.fail_json(msg=error_message) def validate_module(self, job_id, job_state, state): """ Validates the SyncIQ jobs module """ diff --git a/tests/unit/plugins/module_utils/mock_snapshot_api.py b/tests/unit/plugins/module_utils/mock_snapshot_api.py new file mode 100644 index 0000000..a96506d --- /dev/null +++ b/tests/unit/plugins/module_utils/mock_snapshot_api.py @@ -0,0 +1,133 @@ +# Copyright: (c) 2025, Dell Technologies + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Mock API responses for PowerScale Snapshot module""" + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +MODULE_UTILS_PATH = 'ansible_collections.dellemc.powerscale.plugins.modules.snapshot.utils' + +SNAPSHOT = { + "snapshots": [ + { + "alias": "alias_name_1", + "created": 1628155527, + "expires": 10, + "has_locks": False, + "id": 936, + "name": "ansible_test_snapshot", + "path": "/ifs/ansible_test_snapshot", + "pct_filesystem": 2.435778242215747e-06, + "pct_reserve": 0.0, + "schedule": None, + "shadow_bytes": 0, + "size": 4096, + "state": "active", + "target_id": None, + "target_name": None + } + ] +} + +SNAPSHOT_WO_EXPIRES = { + "snapshots": [ + { + "alias": "alias_name_1", + "created": 1628155527, + "has_locks": False, + "id": 936, + "name": "ansible_test_snapshot", + "path": "/ifs/ansible_test_snapshot", + "pct_filesystem": 2.435778242215747e-06, + "pct_reserve": 0.0, + "schedule": None, + "shadow_bytes": 0, + "size": 4096, + "state": "active", + "target_id": None, + "target_name": None + } + ] +} + +ALIAS = { + "snapshots": [ + { + "target_name": "ansible_test_snapshot", + "name": "alias_name_1" + } + ] +} + +CREATE_SNAPSHOT_PARAMS = { + "name": "ansible_test_snapshot", + "path": "/ifs/ansible_test_snapshot", + "alias": "snap_alias_1", + "expires": 60} + +MODIFY_SNAPSHOT_PARAMS = {"expires": 60} + +RENAME_SNAPSHOT_PARAMS = {"name": "renamed_snapshot_name_1"} + + +def create_snapshot_failed_msg(): + return 'Failed to create snapshot' + + +def modify_snapshot_failed_msg(): + return 'Failed to modify snapshot' + + +def rename_snapshot_failed_msg(): + return 'Failed to rename snapshot' + + +def invalid_access_zone_failed_msg(): + return 'Unable to fetch base path of Access Zone invalid_zone ,failed with error: SDK Error message' + + +def get_snapshot_wo_name_failed_msg(): + return 'Please provide a valid snapshot name' + + +def modify_snapshot_wo_desired_retention_failed_msg(): + return 'Specify desired retention along with retention unit.' + + +def delete_snapshot_exception_failed_msg(): + return 'Failed to delete snapshot' + + +def get_snapshot_alias_failed_msg(): + return 'Failed to get alias for snapshot' + + +def create_snapshot_wo_retention_failed_msg(): + return 'Please provide either desired_retention or expiration_timestamp for creating a snapshot' + + +def create_snapshot_with_new_name_failed_msg(): + return 'Invalid param: new_name while creating a new snapshot.' + + +def create_snapshot_without_path_failed_msg(): + return 'Please provide a valid path for snapshot creation' + + +def create_snapshot_wo_desired_retention_failed_msg(): + return 'Desired retention is set to' + + +def create_snapshot_invalid_desired_retention_failed_msg(): + return 'Please provide a valid integer as the desired retention.' + + +def modify_non_existing_path_failed_msg(): + return 'specified in the playbook does not match the path of the snapshot' + + +def get_snapshot_failed_msg(): + return 'Failed to get details of Snapshot' diff --git a/tests/unit/plugins/module_utils/mock_synciqjob_api.py b/tests/unit/plugins/module_utils/mock_synciqjob_api.py new file mode 100644 index 0000000..d96d940 --- /dev/null +++ b/tests/unit/plugins/module_utils/mock_synciqjob_api.py @@ -0,0 +1,149 @@ +# Copyright: (c) 2025, Dell Technologies + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Mock API responses for PowerScale SyncIQ Job module""" + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +MODULE_UTILS_PATH = 'ansible_collections.dellemc.powerscale.plugins.modules.synciqjob.utils' + +SYNCIQ_JOB = { + "job_details": [ + { + "action": "run", + "ads_streams_replicated": 0, + "block_specs_replicated": 0, + "bytes_recoverable": 0, + "bytes_transferred": 0, + "char_specs_replicated": 0, + "committed_files": 0, + "corrected_lins": 0, + "dead_node": False, + "directories_replicated": 0, + "dirs_changed": 0, + "dirs_deleted": 0, + "dirs_moved": 0, + "dirs_new": 0, + "duration": 1, + "encrypted": True, + "end_time": 1687488893, + "error": "", + "error_checksum_files_skipped": 0, + "error_io_files_skipped": 0, + "error_net_files_skipped": 0, + "errors": [], + "failed_chunks": 0, + "fifos_replicated": 0, + "file_data_bytes": 0, + "files_changed": 0, + "files_linked": 0, + "files_new": 0, + "files_selected": 0, + "files_transferred": 0, + "files_unlinked": 0, + "files_with_ads_replicated": 0, + "flipped_lins": 0, + "hard_links_replicated": 0, + "hash_exceptions_fixed": 0, + "hash_exceptions_found": 0, + "id": "test", + "job_id": 1, + "lins_total": 0, + "network_bytes_to_source": 0, + "network_bytes_to_target": 0, + "new_files_replicated": 0, + "num_retransmitted_files": 0, + "phases": [], + "policy": { + "action": "sync", + "file_matching_pattern": { + "or_criteria": None + }, + "name": "test", + "source_exclude_directories": [], + "source_include_directories": [], + "source_root_path": "/ifs/ATest", + "target_host": "10.**.**.**", + "target_path": "/ifs/ATest" + }, + "policy_action": "sync", + "policy_id": "2ed973731814666a9d258db3a8875b5d", + "policy_name": "test", + "quotas_deleted": 0, + "regular_files_replicated": 0, + "resynced_lins": 0, + "retransmitted_files": [], + "retry": 1, + "running_chunks": 0, + "service_report": None, + "sockets_replicated": 0, + "source_bytes_recovered": 0, + "source_directories_created": 0, + "source_directories_deleted": 0, + "source_directories_linked": 0, + "source_directories_unlinked": 0, + "source_directories_visited": 0, + "source_files_deleted": 0, + "source_files_linked": 0, + "source_files_unlinked": 0, + "sparse_data_bytes": 0, + "start_time": 1687488892, + "state": "running", + "succeeded_chunks": 0, + "symlinks_replicated": 0, + "sync_type": "invalid", + "target_bytes_recovered": 0, + "target_directories_created": 0, + "target_directories_deleted": 0, + "target_directories_linked": 0, + "target_directories_unlinked": 0, + "target_files_deleted": 0, + "target_files_linked": 0, + "target_files_unlinked": 0, + "target_snapshots": [], + "throughput": "0 b/s", + "total_chunks": 0, + "total_data_bytes": 0, + "total_exported_services": None, + "total_files": 0, + "total_network_bytes": 0, + "total_phases": 0, + "unchanged_data_bytes": 0, + "up_to_date_files_skipped": 0, + "updated_files_replicated": 0, + "user_conflict_files_skipped": 0, + "warnings": [], + "workers": [], + "worm_committed_file_conflicts": 0 + } + ] +} + +MODIFY_SYNCIQ_JOB_PARAMS = {"state": "pause"} + + +def create_synciq_job_failed_msg(): + return 'Creation of new job is not supported by this ansible module.' + + +def modify_synciq_job_failed_msg(): + return 'Modify state of SyncIQ job' + + +def get_synciq_job_empty_id_failed_msg(): + return 'Please enter a valid job_id.' + + +def modify_synciq_job_state_cancel_failed_msg(): + return 'Please specify the state as absent for cancel.' + + +def get_synciq_job_failed_msg(): + return 'Get details of SyncIQ job' + + +def delete_synciq_job_failed_msg(): + return 'Please specify a valid state.' diff --git a/tests/unit/plugins/modules/test_snapshot.py b/tests/unit/plugins/modules/test_snapshot.py new file mode 100644 index 0000000..c8a4643 --- /dev/null +++ b/tests/unit/plugins/modules/test_snapshot.py @@ -0,0 +1,435 @@ +# Copyright: (c) 2025 Dell Technologies + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Unit Tests for Snapshot module on PowerScale""" + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +import pytest +from mock.mock import MagicMock +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.shared_library.initial_mock \ + import utils + + +from ansible_collections.dellemc.powerscale.plugins.modules.snapshot import Snapshot +from ansible_collections.dellemc.powerscale.tests.unit.plugins.\ + module_utils import mock_snapshot_api as MockSnapshotApi +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.mock_sdk_response \ + import MockSDKResponse +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.mock_api_exception \ + import MockApiException +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.shared_library.powerscale_unit_base \ + import PowerScaleUnitBase +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.mock_fail_json \ + import FailJsonException, fail_json + + +class TestSnapshot(PowerScaleUnitBase): + get_snapshot_args = { + "snapshot_name": None, + "path": None, + "access_zone": 'System', + "new_snapshot_name": None, + "expiration_timestamp": None, + "desired_retention": None, + "retention_unit": None, + "alias": None, + "state": None + } + + snapshot_name_1 = "ansible_test_snapshot" + + @pytest.fixture + def snapshot_module_mock(self, mocker): + mocker.patch(MockSnapshotApi.MODULE_UTILS_PATH + '.ApiException', new=MockApiException) + snapshot_module_mock = Snapshot() + snapshot_module_mock.module = MagicMock() + snapshot_module_mock.module.check_mode = False + snapshot_module_mock.module.fail_json = fail_json + snapshot_module_mock.snapshot_api = MagicMock() + return snapshot_module_mock + + def capture_fail_json_call(self, error_msg, snapshot_module_mock): + try: + snapshot_module_mock.perform_module_operation() + except FailJsonException as fj_object: + assert error_msg in fj_object.message + + def test_get_snapshot_by_name_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.SNAPSHOT)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.get_snapshot_snapshot.assert_called() + + def test_get_snapshot_wo_name_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + self.capture_fail_json_call( + MockSnapshotApi.get_snapshot_wo_name_failed_msg(), snapshot_module_mock) + + def test_get_snapshot_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSnapshotApi.get_snapshot_failed_msg(), snapshot_module_mock) + + def test_create_snapshot_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "2", + "retention_unit": "days", + "alias": "snap_alias_1", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + MockApiException.status = '404' + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock( + side_effect=utils.ApiException) + snapshot_module_mock.isi_sdk.SnapshotSnapshotCreateParams = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.CREATE_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.create_snapshot_snapshot.assert_called() + assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_create_snapshot_wo_retention_unit_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "2", + "alias": "snap_alias_1", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + snapshot_module_mock.isi_sdk.SnapshotSnapshotCreateParams = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.CREATE_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.create_snapshot_snapshot.assert_called() + assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_create_snapshot_retention_unit_hours_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "3", + "retention_unit": "hours", + "alias": "snap_alias_1", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + snapshot_module_mock.isi_sdk.SnapshotSnapshotCreateParams = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.CREATE_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.create_snapshot_snapshot.assert_called() + assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_create_snapshot_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "2", + "retention_unit": "days", + "alias": "snap_alias_1", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + snapshot_module_mock.isi_sdk.SnapshotSnapshotCreateParams = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.CREATE_SNAPSHOT_PARAMS)) + snapshot_module_mock.snapshot_api.create_snapshot_snapshot = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSnapshotApi.create_snapshot_failed_msg(), snapshot_module_mock) + + def test_create_snapshot_wo_desired_retention_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "none", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + self.capture_fail_json_call( + MockSnapshotApi.create_snapshot_wo_desired_retention_failed_msg(), snapshot_module_mock) + + def test_create_snapshot_invalid_desired_retention_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "string", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + self.capture_fail_json_call( + MockSnapshotApi.create_snapshot_invalid_desired_retention_failed_msg(), snapshot_module_mock) + + def test_create_snapshot_wo_retention_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + self.capture_fail_json_call( + MockSnapshotApi.create_snapshot_wo_retention_failed_msg(), snapshot_module_mock) + + def test_create_snapshot_with_new_name_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "2", + "retention_unit": "days", + "alias": "snap_alias_1", + "new_snapshot_name": "new_name", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + self.capture_fail_json_call( + MockSnapshotApi.create_snapshot_with_new_name_failed_msg(), snapshot_module_mock) + + def test_create_snapshot_without_path_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"access_zone": "sample_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "2", + "retention_unit": "days", + "alias": "snap_alias_1", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + self.capture_fail_json_call( + MockSnapshotApi.create_snapshot_without_path_failed_msg(), snapshot_module_mock) + + def test_create_snapshot_invalid_access_zone_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + "access_zone": "invalid_zone", + "snapshot_name": "ansible_test_snapshot", + "desired_retention": "2", + "retention_unit": "days", + "alias": "snap_alias_1", + "state": "present"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock(return_value=None) + snapshot_module_mock.zone_summary_api.get_zones_summary_zone = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSnapshotApi.invalid_access_zone_failed_msg(), + snapshot_module_mock) + + def test_rename_snapshot_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "new_snapshot_name": "renamed_snapshot_name_1", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot.to_dict = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.RENAME_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.update_snapshot_snapshot.assert_called() + assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_rename_snapshot_same_name_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "new_snapshot_name": self.snapshot_name_1, + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.perform_module_operation() + assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is False + + def test_rename_snapshot_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "new_snapshot_name": "renamed_snapshot_name_1", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.snapshot_api.get_snapshot_snapshot.to_dict = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.SNAPSHOT)) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.RENAME_SNAPSHOT_PARAMS)) + snapshot_module_mock.snapshot_api.update_snapshot_snapshot = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSnapshotApi.rename_snapshot_failed_msg(), snapshot_module_mock) + + def test_get_snapshot_alias_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "alias": "alias_name_2", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.snapshot_api.list_snapshot_snapshots.to_dict = MagicMock( + return_value=MockSnapshotApi.ALIAS) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.list_snapshot_snapshots.assert_called() + + def test_get_snapshot_alias_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "alias": "alias_name_2", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.snapshot_api.list_snapshot_snapshots = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSnapshotApi.get_snapshot_alias_failed_msg(), snapshot_module_mock) + + def test_modify_snapshot_expiration_timestamp_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "expiration_timestamp": '2025-01-18T11:50:20Z', + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.MODIFY_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.update_snapshot_snapshot.assert_called() + assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_modify_snapshot_expiration_timestamp_wo_expires_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "expiration_timestamp": '2025-01-18T11:50:20Z', + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT_WO_EXPIRES) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.MODIFY_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.update_snapshot_snapshot.assert_called() + assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_modify_non_existing_path_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "path": "/ifs/non_existing_path", + "expiration_timestamp": '2025-01-18T11:50:20Z', + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + self.capture_fail_json_call( + MockSnapshotApi.modify_non_existing_path_failed_msg(), snapshot_module_mock) + + def test_modify_snapshot_expiration_timestamp_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "expiration_timestamp": '2025-01-18T11:50:20Z', + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.MODIFY_SNAPSHOT_PARAMS)) + snapshot_module_mock.snapshot_api.update_snapshot_snapshot = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSnapshotApi.modify_snapshot_failed_msg(), snapshot_module_mock) + + def test_modify_snapshot_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "desired_retention": "2", + "retention_unit": "days", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.MODIFY_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.update_snapshot_snapshot.assert_called() + + def test_modify_snapshot_no_retention_unit_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "desired_retention": "2", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.MODIFY_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.update_snapshot_snapshot.assert_called() + + def test_modify_snapshot_retention_unit_hours_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "desired_retention": "2", + "retention_unit": "hours", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.MODIFY_SNAPSHOT_PARAMS)) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.update_snapshot_snapshot.assert_called() + + def test_modify_snapshot_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "desired_retention": "2", + "retention_unit": "days", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.MODIFY_SNAPSHOT_PARAMS)) + snapshot_module_mock.snapshot_api.update_snapshot_snapshot = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSnapshotApi.modify_snapshot_failed_msg(), snapshot_module_mock) + + def test_modify_snapshot_wo_desired_retention_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "retention_unit": "days", + "state": "present"}) + + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.isi_sdk.SnapshotSnapshot = MagicMock( + return_value=MockSDKResponse(MockSnapshotApi.MODIFY_SNAPSHOT_PARAMS)) + self.capture_fail_json_call( + MockSnapshotApi.modify_snapshot_wo_desired_retention_failed_msg(), snapshot_module_mock) + + def test_delete_snapshot_by_name_response(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "state": "absent"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.perform_module_operation() + snapshot_module_mock.snapshot_api.delete_snapshot_snapshot.assert_called() + assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_delete_snapshot_exception(self, snapshot_module_mock): + self.get_snapshot_args.update({"snapshot_name": self.snapshot_name_1, + "state": "absent"}) + snapshot_module_mock.module.params = self.get_snapshot_args + snapshot_module_mock.get_filesystem_snapshot_details = MagicMock( + return_value=MockSnapshotApi.SNAPSHOT) + snapshot_module_mock.snapshot_api.delete_snapshot_snapshot = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSnapshotApi.delete_snapshot_exception_failed_msg(), snapshot_module_mock) diff --git a/tests/unit/plugins/modules/test_synciqjob.py b/tests/unit/plugins/modules/test_synciqjob.py new file mode 100644 index 0000000..599a3a4 --- /dev/null +++ b/tests/unit/plugins/modules/test_synciqjob.py @@ -0,0 +1,154 @@ +# Copyright: (c) 2025 Dell Technologies + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Unit Tests for SyncIQ Job module on PowerScale""" + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +import pytest +from mock.mock import MagicMock +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.shared_library.initial_mock \ + import utils + + +from ansible_collections.dellemc.powerscale.plugins.modules.synciqjob import SyncIQJob +from ansible_collections.dellemc.powerscale.tests.unit.plugins.\ + module_utils import mock_synciqjob_api as MockSyncIQJobApi +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.mock_sdk_response \ + import MockSDKResponse +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.mock_api_exception \ + import MockApiException +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.shared_library.powerscale_unit_base \ + import PowerScaleUnitBase +from ansible_collections.dellemc.powerscale.tests.unit.plugins.module_utils.mock_fail_json \ + import FailJsonException, fail_json + + +class TestSyncIQJob(PowerScaleUnitBase): + get_synciq_job_args = { + "job_id": None, + "job_state": None, + "state": None + } + + snapshot_name_1 = "ansible_test_snapshot" + + @pytest.fixture + def synciq_job_module_mock(self, mocker): + mocker.patch(MockSyncIQJobApi.MODULE_UTILS_PATH + '.ApiException', new=MockApiException) + synciq_job_module_mock = SyncIQJob() + synciq_job_module_mock.module = MagicMock() + synciq_job_module_mock.module.check_mode = False + synciq_job_module_mock.module.fail_json = fail_json + synciq_job_module_mock.sync_api_instance = MagicMock() + return synciq_job_module_mock + + def capture_fail_json_call(self, error_msg, synciq_job_module_mock): + try: + synciq_job_module_mock.perform_module_operation() + except FailJsonException as fj_object: + assert error_msg in fj_object.message + + # def test_get_job_details_by_id_response(self, synciq_job_module_mock): + # self.get_synciq_job_args.update({"job_id": "test", + # "state": "present"}) + # synciq_job_module_mock.module.params = self.get_synciq_job_args + # synciq_job_module_mock.sync_api_instance.list_sync_jobs.jobs = MagicMock( + # return_value=MockSDKResponse(MockSyncIQJobApi.SYNCIQ_JOB["job_details"])) + # synciq_job_module_mock.perform_module_operation() + # synciq_job_module_mock.sync_api_instance.list_sync_jobs.assert_called() + + # def test_get_synciq_404_exception(self, snapshot_module_mock): + # self.get_snapshot_args.update({"path": "/ifs/ansible_test_snapshot", + # "access_zone": "sample_zone", + # "snapshot_name": "ansible_test_snapshot", + # "desired_retention": "2", + # "retention_unit": "days", + # "alias": "snap_alias_1", + # "state": "present"}) + # snapshot_module_mock.module.params = self.get_snapshot_args + # MockApiException.status = '404' + # snapshot_module_mock.snapshot_api.get_snapshot_snapshot = MagicMock( + # side_effect=utils.ApiException) + # snapshot_module_mock.isi_sdk.SnapshotSnapshotCreateParams = MagicMock( + # return_value=MockSDKResponse(MockSnapshotApi.CREATE_SNAPSHOT_PARAMS)) + # snapshot_module_mock.perform_module_operation() + # snapshot_module_mock.snapshot_api.create_snapshot_snapshot.assert_called() + # assert snapshot_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_get_job_details_exception(self, synciq_job_module_mock): + self.get_synciq_job_args.update({ + "job_id": "test", + "state": "present"}) + synciq_job_module_mock.module.params = self.get_synciq_job_args + synciq_job_module_mock.sync_api_instance.list_sync_jobs = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSyncIQJobApi.get_synciq_job_failed_msg(), synciq_job_module_mock) + + def test_get_job_details_empty_id_exception(self, synciq_job_module_mock): + self.get_synciq_job_args.update({ + "job_id": "", + "state": "present"}) + synciq_job_module_mock.module.params = self.get_synciq_job_args + self.capture_fail_json_call( + MockSyncIQJobApi.get_synciq_job_empty_id_failed_msg(), synciq_job_module_mock) + + def test_modify_synciq_job_state_cancel_exception(self, synciq_job_module_mock): + self.get_synciq_job_args.update({ + "job_id": "Test", + "job_state": 'cancel', + "state": "present"}) + synciq_job_module_mock.module.params = self.get_synciq_job_args + self.capture_fail_json_call( + MockSyncIQJobApi.modify_synciq_job_state_cancel_failed_msg(), synciq_job_module_mock) + + def test_delete_synciq_job_exception(self, synciq_job_module_mock): + self.get_synciq_job_args.update({ + "job_id": "Test", + "state": "absent"}) + synciq_job_module_mock.module.params = self.get_synciq_job_args + self.capture_fail_json_call( + MockSyncIQJobApi.delete_synciq_job_failed_msg(), synciq_job_module_mock) + + def test_create_synciq_job_exception(self, synciq_job_module_mock): + self.get_synciq_job_args.update({"job_id": "non_existing_job", + "state": "present"}) + synciq_job_module_mock.module.params = self.get_synciq_job_args + MockApiException.status = '404' + synciq_job_module_mock.sync_api_instance.list_sync_jobs = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSyncIQJobApi.create_synciq_job_failed_msg(), synciq_job_module_mock) + + def test_modify_synciq_job_state_response(self, synciq_job_module_mock): + self.get_synciq_job_args.update({"job_id": "test", + "job_state": 'pause', + "state": "present"}) + + synciq_job_module_mock.module.params = self.get_synciq_job_args + synciq_job_module_mock.get_job_details = MagicMock( + return_value=MockSyncIQJobApi.SYNCIQ_JOB["job_details"]) + utils.isi_sdk.SyncJob = MagicMock( + return_value=MockSDKResponse(MockSyncIQJobApi.MODIFY_SYNCIQ_JOB_PARAMS)) + synciq_job_module_mock.perform_module_operation() + synciq_job_module_mock.sync_api_instance.update_sync_job.assert_called() + assert synciq_job_module_mock.module.exit_json.call_args[1]['changed'] is True + + def test_modify_synciq_job_state_exception(self, synciq_job_module_mock): + self.get_synciq_job_args.update({"job_id": "test", + "job_state": 'pause', + "state": "present"}) + + synciq_job_module_mock.module.params = self.get_synciq_job_args + synciq_job_module_mock.get_job_details = MagicMock( + return_value=MockSyncIQJobApi.SYNCIQ_JOB["job_details"]) + utils.isi_sdk.SyncJob = MagicMock( + return_value=MockSDKResponse(MockSyncIQJobApi.MODIFY_SYNCIQ_JOB_PARAMS)) + synciq_job_module_mock.sync_api_instance.update_sync_job = MagicMock( + side_effect=utils.ApiException) + self.capture_fail_json_call( + MockSyncIQJobApi.modify_synciq_job_failed_msg(), synciq_job_module_mock)