diff --git a/changelogs/fragments/994_purefa_pgsnap_restore_all.yaml b/changelogs/fragments/994_purefa_pgsnap_restore_all.yaml new file mode 100644 index 00000000..49e4f0ed --- /dev/null +++ b/changelogs/fragments/994_purefa_pgsnap_restore_all.yaml @@ -0,0 +1,2 @@ +minor_changes: + - purefa_pgsnap - Added ``restore=all`` option to restore all member volumes from a protection group snapshot at once using a single API call instead of iterating through each volume individually diff --git a/plugins/modules/purefa_pgsnap.py b/plugins/modules/purefa_pgsnap.py index a9ef2d8b..748a0dca 100644 --- a/plugins/modules/purefa_pgsnap.py +++ b/plugins/modules/purefa_pgsnap.py @@ -51,13 +51,20 @@ default: false restore: description: - - Restore a specific volume from a protection group snapshot. + - Restore a specific volume from a protection group snapshot, or use C(all) + to restore all member volumes at once. - The protection group name is not required. Only provide the name of the - volume to be restored. + volume to be restored, or C(all) to restore all volumes. + - When using C(all), if restoring to an existing protection group the + I(overwrite) parameter must be set to C(true). + - When using C(all), the I(target) parameter can specify a new or existing + protection group name. If not specified, it defaults to the source + protection group name. type: str overwrite: description: - Define whether to overwrite the target volume if it already exists. + - Required when I(restore=all) and restoring to an existing protection group. type: bool default: false target: @@ -65,6 +72,8 @@ - Volume to restore a specified volume to. - If not supplied this will default to the volume defined in I(restore) - Name of new snapshot suffix if renaming a snapshot + - When I(restore=all), this specifies the target protection group name. + If not supplied, defaults to the source protection group name. type: str offload: description: @@ -196,6 +205,26 @@ fa_url: 10.10.10.2 api_token: e31060a7-21fc-e277-6240-25983c6c4592 state: rename + +- name: Restore all volumes from protection group snapshot foo.snap (overwrite existing) + purestorage.flasharray.purefa_pgsnap: + name: foo + suffix: snap + restore: all + overwrite: true + fa_url: 10.10.10.2 + api_token: e31060a7-21fc-e277-6240-25983c6c4592 + state: copy + +- name: Clone all volumes from protection group snapshot foo.snap to new protection group foo_clone + purestorage.flasharray.purefa_pgsnap: + name: foo + suffix: snap + restore: all + target: foo_clone + fa_url: 10.10.10.2 + api_token: e31060a7-21fc-e277-6240-25983c6c4592 + state: copy """ RETURN = r""" @@ -687,6 +716,80 @@ def restore_pgsnapvolume(module, array): module.exit_json(changed=changed) +def restore_pgsnapshot_all(module, array): + """Restore all volumes from a Protection Group Snapshot""" + api_version = array.get_rest_version() + changed = True + + # Handle 'latest' suffix + if module.params["suffix"] == "latest": + if LooseVersion(CONTEXT_API_VERSION) <= LooseVersion(api_version): + latest_snapshot = list( + array.get_protection_group_snapshots( + names=[module.params["name"]], + context_names=[module.params["context"]], + ).items + )[-1].suffix + else: + latest_snapshot = list( + array.get_protection_group_snapshots( + names=[module.params["name"]] + ).items + )[-1].suffix + module.params["suffix"] = latest_snapshot + + # Source is the pgroup snapshot: pgname.suffix + source_snapshot = module.params["name"] + "." + module.params["suffix"] + + # Target defaults to original protection group if not specified + if module.params["target"]: + target_pgroup = module.params["target"] + else: + target_pgroup = module.params["name"] + + # Check if target protection group exists + target_exists = False + if LooseVersion(CONTEXT_API_VERSION) <= LooseVersion(api_version): + res = array.get_protection_groups( + names=[target_pgroup], context_names=[module.params["context"]] + ) + else: + res = array.get_protection_groups(names=[target_pgroup]) + if res.status_code == 200: + items = list(res.items) + if items: + target_exists = True + + # Validate: overwrite required if target exists + if target_exists and not module.params["overwrite"]: + module.fail_json( + msg="overwrite must be True when restoring to existing protection group '{0}'".format( + target_pgroup + ) + ) + + if not module.check_mode: + if LooseVersion(CONTEXT_API_VERSION) <= LooseVersion(api_version): + res = array.post_protection_groups( + names=[target_pgroup], + source_names=[source_snapshot], + overwrite=module.params["overwrite"], + context_names=[module.params["context"]], + ) + else: + res = array.post_protection_groups( + names=[target_pgroup], + source_names=[source_snapshot], + overwrite=module.params["overwrite"], + ) + check_response( + res, + module, + f"Failed to restore all volumes from pgroup snapshot {source_snapshot}", + ) + module.exit_json(changed=changed) + + def delete_offload_snapshot(module, array): """Delete Offloaded Protection Group Snapshot""" changed = False @@ -912,7 +1015,13 @@ def main(): ) ) - if not module.params["target"] and module.params["restore"]: + # For single volume restore, default target to restore volume name + # For restore=all, target defaults to source pgroup name (handled in function) + if ( + not module.params["target"] + and module.params["restore"] + and module.params["restore"] != "all" + ): module.params["target"] = module.params["restore"] if state == "rename" and module.params["target"] is not None: @@ -923,6 +1032,14 @@ def main(): ) ) array = get_array(module) + api_version = array.get_rest_version() + + # If context is empty and API supports context, set it to current array name + if not module.params["context"] and LooseVersion( + CONTEXT_API_VERSION + ) <= LooseVersion(api_version): + module.params["context"] = list(array.get_arrays().items)[0].name + pgroup = get_pgroup(module, array) if not pgroup: module.fail_json( @@ -936,13 +1053,26 @@ def main(): msg="offload parameter not supported for state {0}".format(state) ) elif state == "copy": - if module.params["overwrite"] and ( - module.params["add_to_pgs"] or module.params["with_default_protection"] - ): - module.fail_json( - msg="overwrite and add_to_pgs or with_default_protection are incompatible" - ) - restore_pgsnapvolume(module, array) + if module.params["restore"] == "all": + # Restore all volumes from pgroup snapshot + # add_to_pgs and with_default_protection are not supported by post_protection_groups() + if ( + module.params["add_to_pgs"] + or not module.params["with_default_protection"] + ): + module.fail_json( + msg="add_to_pgs and with_default_protection are not supported when restore=all" + ) + restore_pgsnapshot_all(module, array) + else: + # Restore single volume + if module.params["overwrite"] and ( + module.params["add_to_pgs"] or module.params["with_default_protection"] + ): + module.fail_json( + msg="overwrite and add_to_pgs or with_default_protection are incompatible" + ) + restore_pgsnapvolume(module, array) elif state == "present" and not pgsnap: create_pgsnapshot(module, array) elif state == "present" and pgsnap: diff --git a/tests/unit/plugins/modules/test_purefa_pgsnap.py b/tests/unit/plugins/modules/test_purefa_pgsnap.py index ba0534f5..bd43d6cc 100644 --- a/tests/unit/plugins/modules/test_purefa_pgsnap.py +++ b/tests/unit/plugins/modules/test_purefa_pgsnap.py @@ -54,6 +54,7 @@ update_pgsnapshot, delete_pgsnapshot, eradicate_pgsnapshot, + restore_pgsnapshot_all, ) @@ -1728,12 +1729,18 @@ def test_create_pgsnapshot_legacy_api_with_remote( class TestMain: """Test main function branches""" + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @patch("plugins.modules.purefa_pgsnap.AnsibleModule") def test_main_pgroup_not_found( - self, mock_ansible, mock_get_pgsnapshot, mock_get_pgroup, mock_get_array + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_lv, ): """Test main fails when protection group doesn't exist""" from plugins.modules.purefa_pgsnap import main @@ -1749,7 +1756,12 @@ def test_main_pgroup_not_found( "context": "", } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = False main() @@ -1757,12 +1769,18 @@ def test_main_pgroup_not_found( mock_module.fail_json.assert_called_once() assert "does not exist" in str(mock_module.fail_json.call_args) + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @patch("plugins.modules.purefa_pgsnap.AnsibleModule") def test_main_offload_not_supported_for_present( - self, mock_ansible, mock_get_pgsnapshot, mock_get_pgroup, mock_get_array + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_lv, ): """Test main fails when offload used with state=present""" from plugins.modules.purefa_pgsnap import main @@ -1778,7 +1796,12 @@ def test_main_offload_not_supported_for_present( "context": "", } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_get_pgsnapshot.return_value = None @@ -1787,6 +1810,7 @@ def test_main_offload_not_supported_for_present( mock_module.fail_json.assert_called_once() assert "offload parameter not supported" in str(mock_module.fail_json.call_args) + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @@ -1799,6 +1823,7 @@ def test_main_copy_with_overwrite_and_add_to_pgs_fails( mock_get_pgsnapshot, mock_get_pgroup, mock_get_array, + mock_lv, ): """Test main fails when copy with overwrite and add_to_pgs""" from plugins.modules.purefa_pgsnap import main @@ -1817,7 +1842,12 @@ def test_main_copy_with_overwrite_and_add_to_pgs_fails( "with_default_protection": True, } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_get_pgsnapshot.return_value = None @@ -1826,12 +1856,18 @@ def test_main_copy_with_overwrite_and_add_to_pgs_fails( mock_module.fail_json.assert_called_once() assert "overwrite and add_to_pgs" in str(mock_module.fail_json.call_args) + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @patch("plugins.modules.purefa_pgsnap.AnsibleModule") def test_main_present_snapshot_exists_no_change( - self, mock_ansible, mock_get_pgsnapshot, mock_get_pgroup, mock_get_array + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_lv, ): """Test main exits unchanged when snapshot already exists""" from plugins.modules.purefa_pgsnap import main @@ -1847,7 +1883,12 @@ def test_main_present_snapshot_exists_no_change( "context": "", } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_snap = Mock() mock_snap.destroyed = False @@ -1857,12 +1898,18 @@ def test_main_present_snapshot_exists_no_change( mock_module.exit_json.assert_called_with(changed=False) + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @patch("plugins.modules.purefa_pgsnap.AnsibleModule") def test_main_absent_snapshot_not_exists_no_change( - self, mock_ansible, mock_get_pgsnapshot, mock_get_pgroup, mock_get_array + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_lv, ): """Test main exits unchanged when absent and snapshot doesn't exist""" from plugins.modules.purefa_pgsnap import main @@ -1879,7 +1926,12 @@ def test_main_absent_snapshot_not_exists_no_change( "eradicate": False, } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_get_pgsnapshot.return_value = None @@ -1887,6 +1939,7 @@ def test_main_absent_snapshot_not_exists_no_change( mock_module.exit_json.assert_called_with(changed=False) + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @@ -1899,6 +1952,7 @@ def test_main_rename_calls_update( mock_get_pgsnapshot, mock_get_pgroup, mock_get_array, + mock_lv, ): """Test main calls update_pgsnapshot for rename state""" from plugins.modules.purefa_pgsnap import main @@ -1914,7 +1968,12 @@ def test_main_rename_calls_update( "context": "", } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_snap = Mock() mock_snap.destroyed = False @@ -1924,6 +1983,7 @@ def test_main_rename_calls_update( mock_update.assert_called_once() + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @@ -1936,6 +1996,7 @@ def test_main_absent_calls_delete( mock_get_pgsnapshot, mock_get_pgroup, mock_get_array, + mock_lv, ): """Test main calls delete_pgsnapshot for absent state""" from plugins.modules.purefa_pgsnap import main @@ -1952,7 +2013,12 @@ def test_main_absent_calls_delete( "eradicate": False, } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_snap = Mock() mock_snap.destroyed = False @@ -1962,6 +2028,7 @@ def test_main_absent_calls_delete( mock_delete.assert_called_once() + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @@ -1974,6 +2041,7 @@ def test_main_absent_destroyed_eradicate_calls_eradicate( mock_get_pgsnapshot, mock_get_pgroup, mock_get_array, + mock_lv, ): """Test main calls eradicate_pgsnapshot for destroyed snapshot with eradicate""" from plugins.modules.purefa_pgsnap import main @@ -1990,7 +2058,12 @@ def test_main_absent_destroyed_eradicate_calls_eradicate( "eradicate": True, } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_snap = Mock() mock_snap.destroyed = True @@ -2000,6 +2073,7 @@ def test_main_absent_destroyed_eradicate_calls_eradicate( mock_eradicate.assert_called_once() + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @@ -2012,6 +2086,7 @@ def test_main_absent_with_offload_calls_delete_offload( mock_get_pgsnapshot, mock_get_pgroup, mock_get_array, + mock_lv, ): """Test main calls delete_offload_snapshot for absent with offload""" from plugins.modules.purefa_pgsnap import main @@ -2028,7 +2103,12 @@ def test_main_absent_with_offload_calls_delete_offload( "eradicate": False, } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_snap = Mock() mock_snap.destroyed = False @@ -2038,6 +2118,7 @@ def test_main_absent_with_offload_calls_delete_offload( mock_delete_offload.assert_called_once() + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.HAS_PURESTORAGE", True) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @@ -2049,6 +2130,7 @@ def test_main_suffix_validation_fails( mock_get_pgsnapshot, mock_get_pgroup, mock_get_array, + mock_lv, ): """Test main fails on invalid suffix name""" from plugins.modules.purefa_pgsnap import main @@ -2080,12 +2162,18 @@ def test_main_suffix_validation_fails( mock_module.fail_json.call_args ) + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) @patch("plugins.modules.purefa_pgsnap.get_array") @patch("plugins.modules.purefa_pgsnap.get_pgroup") @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") @patch("plugins.modules.purefa_pgsnap.AnsibleModule") def test_main_rename_target_validation_fails( - self, mock_ansible, mock_get_pgsnapshot, mock_get_pgroup, mock_get_array + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_lv, ): """Test main fails on invalid rename target""" from plugins.modules.purefa_pgsnap import main @@ -2101,7 +2189,12 @@ def test_main_rename_target_validation_fails( "context": "", } mock_ansible.return_value = mock_module - mock_get_array.return_value = Mock() + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array mock_get_pgroup.return_value = True mock_snap = Mock() mock_snap.destroyed = False @@ -2113,3 +2206,408 @@ def test_main_rename_target_validation_fails( assert "does not conform to suffix name rules" in str( mock_module.fail_json.call_args ) + + +class TestRestorePgsnapshotAll: + """Tests for restore_pgsnapshot_all function""" + + @patch("plugins.modules.purefa_pgsnap.check_response") + @patch("plugins.modules.purefa_pgsnap.LooseVersion") + def test_restore_all_to_new_pgroup(self, mock_loose, mock_check): + """Test restore all volumes to a new protection group""" + mock_loose.side_effect = LooseVersion + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "target": "pg1_clone", + "overwrite": False, + "context": "", + } + mock_module.check_mode = False + + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + # Target doesn't exist + mock_array.get_protection_groups.return_value = Mock( + status_code=200, items=iter([]) + ) + mock_array.post_protection_groups.return_value = Mock(status_code=200) + + restore_pgsnapshot_all(mock_module, mock_array) + + mock_array.post_protection_groups.assert_called_once_with( + names=["pg1_clone"], + source_names=["pg1.snap1"], + overwrite=False, + context_names=[""], + ) + mock_module.exit_json.assert_called_once_with(changed=True) + + @patch("plugins.modules.purefa_pgsnap.check_response") + @patch("plugins.modules.purefa_pgsnap.LooseVersion") + def test_restore_all_overwrite_existing(self, mock_loose, mock_check): + """Test restore all volumes overwriting existing protection group""" + mock_loose.side_effect = LooseVersion + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "target": None, # Defaults to source pg name + "overwrite": True, + "context": "", + } + mock_module.check_mode = False + + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + # Target exists + mock_pgroup = Mock() + mock_array.get_protection_groups.return_value = Mock( + status_code=200, items=iter([mock_pgroup]) + ) + mock_array.post_protection_groups.return_value = Mock(status_code=200) + + restore_pgsnapshot_all(mock_module, mock_array) + + mock_array.post_protection_groups.assert_called_once_with( + names=["pg1"], + source_names=["pg1.snap1"], + overwrite=True, + context_names=[""], + ) + mock_module.exit_json.assert_called_once_with(changed=True) + + @patch("plugins.modules.purefa_pgsnap.LooseVersion") + def test_restore_all_existing_without_overwrite_fails(self, mock_loose): + """Test restore all fails when target exists and overwrite=False""" + mock_loose.side_effect = LooseVersion + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "target": None, + "overwrite": False, + "context": "", + } + mock_module.check_mode = False + + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + # Target exists + mock_pgroup = Mock() + mock_array.get_protection_groups.return_value = Mock( + status_code=200, items=iter([mock_pgroup]) + ) + + restore_pgsnapshot_all(mock_module, mock_array) + + mock_module.fail_json.assert_called_once() + assert "overwrite must be True" in str(mock_module.fail_json.call_args) + + @patch("plugins.modules.purefa_pgsnap.check_response") + @patch("plugins.modules.purefa_pgsnap.LooseVersion") + def test_restore_all_with_latest_suffix(self, mock_loose, mock_check): + """Test restore all with 'latest' suffix resolves to actual suffix""" + mock_loose.side_effect = LooseVersion + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "latest", + "target": "pg1_clone", + "overwrite": False, + "context": "", + } + mock_module.check_mode = False + + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + # Latest snapshot + mock_snap = Mock() + mock_snap.suffix = "snap-actual" + mock_array.get_protection_group_snapshots.return_value = Mock( + items=iter([mock_snap]) + ) + # Target doesn't exist + mock_array.get_protection_groups.return_value = Mock( + status_code=200, items=iter([]) + ) + mock_array.post_protection_groups.return_value = Mock(status_code=200) + + restore_pgsnapshot_all(mock_module, mock_array) + + # Should use the resolved suffix + mock_array.post_protection_groups.assert_called_once_with( + names=["pg1_clone"], + source_names=["pg1.snap-actual"], + overwrite=False, + context_names=[""], + ) + + @patch("plugins.modules.purefa_pgsnap.LooseVersion") + def test_restore_all_check_mode(self, mock_loose): + """Test restore all in check mode doesn't make API calls""" + mock_loose.side_effect = LooseVersion + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "target": "pg1_clone", + "overwrite": False, + "context": "", + } + mock_module.check_mode = True + + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + # Target doesn't exist + mock_array.get_protection_groups.return_value = Mock( + status_code=200, items=iter([]) + ) + + restore_pgsnapshot_all(mock_module, mock_array) + + mock_array.post_protection_groups.assert_not_called() + mock_module.exit_json.assert_called_once_with(changed=True) + + @patch("plugins.modules.purefa_pgsnap.check_response") + @patch("plugins.modules.purefa_pgsnap.LooseVersion") + def test_restore_all_older_api_version(self, mock_loose, mock_check): + """Test restore all with older API version (no context support)""" + mock_loose.side_effect = LooseVersion + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "target": "pg1_clone", + "overwrite": False, + "context": "", + } + mock_module.check_mode = False + + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.20" # Older version + # Target doesn't exist + mock_array.get_protection_groups.return_value = Mock( + status_code=200, items=iter([]) + ) + mock_array.post_protection_groups.return_value = Mock(status_code=200) + + restore_pgsnapshot_all(mock_module, mock_array) + + # Should not include context_names + mock_array.post_protection_groups.assert_called_once_with( + names=["pg1_clone"], + source_names=["pg1.snap1"], + overwrite=False, + ) + + +class TestMainRestoreAll: + """Tests for main() function with restore=all""" + + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) + @patch("plugins.modules.purefa_pgsnap.restore_pgsnapshot_all") + @patch("plugins.modules.purefa_pgsnap.get_array") + @patch("plugins.modules.purefa_pgsnap.get_pgroup") + @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") + @patch("plugins.modules.purefa_pgsnap.AnsibleModule") + def test_main_restore_all_calls_restore_pgsnapshot_all( + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_restore_all, + mock_lv, + ): + """Test main() calls restore_pgsnapshot_all when restore=all""" + from plugins.modules.purefa_pgsnap import main + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "state": "copy", + "restore": "all", + "target": None, + "offload": None, + "overwrite": True, + "add_to_pgs": None, + "with_default_protection": True, + "context": "", + } + mock_ansible.return_value = mock_module + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array + mock_get_pgroup.return_value = True + mock_snap = Mock() + mock_snap.destroyed = False + mock_get_pgsnapshot.return_value = mock_snap + + main() + + mock_restore_all.assert_called_once_with(mock_module, mock_array) + + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) + @patch("plugins.modules.purefa_pgsnap.restore_pgsnapshot_all") + @patch("plugins.modules.purefa_pgsnap.get_array") + @patch("plugins.modules.purefa_pgsnap.get_pgroup") + @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") + @patch("plugins.modules.purefa_pgsnap.AnsibleModule") + def test_main_restore_all_fails_with_add_to_pgs( + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_restore_all, + mock_lv, + ): + """Test main() fails when restore=all with add_to_pgs""" + import pytest + from plugins.modules.purefa_pgsnap import main + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "state": "copy", + "restore": "all", + "target": None, + "offload": None, + "overwrite": True, + "add_to_pgs": ["pg2"], # Not supported with restore=all + "with_default_protection": True, + "context": "", + } + mock_module.fail_json.side_effect = SystemExit(1) + mock_ansible.return_value = mock_module + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array + mock_get_pgroup.return_value = True + mock_snap = Mock() + mock_snap.destroyed = False + mock_get_pgsnapshot.return_value = mock_snap + + with pytest.raises(SystemExit): + main() + + mock_module.fail_json.assert_called_once() + assert "not supported when restore=all" in str(mock_module.fail_json.call_args) + mock_restore_all.assert_not_called() + + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) + @patch("plugins.modules.purefa_pgsnap.restore_pgsnapshot_all") + @patch("plugins.modules.purefa_pgsnap.get_array") + @patch("plugins.modules.purefa_pgsnap.get_pgroup") + @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") + @patch("plugins.modules.purefa_pgsnap.AnsibleModule") + def test_main_restore_all_fails_with_default_protection_false( + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_restore_all, + mock_lv, + ): + """Test main() fails when restore=all with with_default_protection=False""" + import pytest + from plugins.modules.purefa_pgsnap import main + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "state": "copy", + "restore": "all", + "target": None, + "offload": None, + "overwrite": True, + "add_to_pgs": None, + "with_default_protection": False, # Not supported with restore=all + "context": "", + } + mock_module.fail_json.side_effect = SystemExit(1) + mock_ansible.return_value = mock_module + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array + mock_get_pgroup.return_value = True + mock_snap = Mock() + mock_snap.destroyed = False + mock_get_pgsnapshot.return_value = mock_snap + + with pytest.raises(SystemExit): + main() + + mock_module.fail_json.assert_called_once() + assert "not supported when restore=all" in str(mock_module.fail_json.call_args) + mock_restore_all.assert_not_called() + + @patch("plugins.modules.purefa_pgsnap.LooseVersion", side_effect=LooseVersion) + @patch("plugins.modules.purefa_pgsnap.restore_pgsnapshot_all") + @patch("plugins.modules.purefa_pgsnap.get_array") + @patch("plugins.modules.purefa_pgsnap.get_pgroup") + @patch("plugins.modules.purefa_pgsnap.get_pgsnapshot") + @patch("plugins.modules.purefa_pgsnap.AnsibleModule") + def test_main_restore_all_target_not_defaulted_to_all( + self, + mock_ansible, + mock_get_pgsnapshot, + mock_get_pgroup, + mock_get_array, + mock_restore_all, + mock_lv, + ): + """Test that target doesn't default to 'all' when restore=all""" + from plugins.modules.purefa_pgsnap import main + + mock_module = Mock() + mock_module.params = { + "name": "pg1", + "suffix": "snap1", + "state": "copy", + "restore": "all", + "target": None, # Should stay None, not become "all" + "offload": None, + "overwrite": True, + "add_to_pgs": None, + "with_default_protection": True, + "context": "", + } + mock_ansible.return_value = mock_module + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.38" + mock_array_info = Mock() + mock_array_info.name = "array1" + mock_array.get_arrays.return_value = Mock(items=iter([mock_array_info])) + mock_get_array.return_value = mock_array + mock_get_pgroup.return_value = True + mock_snap = Mock() + mock_snap.destroyed = False + mock_get_pgsnapshot.return_value = mock_snap + + main() + + # Verify target was not changed to "all" + assert mock_module.params["target"] is None + mock_restore_all.assert_called_once()