From 678de7117211fc359c9aa7e29f6c2fecf0944b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Su=C3=A1rez=20Hern=C3=A1ndez?= Date: Fri, 27 Jan 2017 17:07:25 +0000 Subject: [PATCH] Snapper module improvements * Snapper: Adding support for deleting snapshots * Snapper: Adding support for snapshot metadata modification * Snapper: Adding support for creating configurations * Adds 'snapper.delete_snapshots' unit tests * Adds 'snapper.modify_snapshots' unit tests * Adds 'snapper.create_config' unit tests * Removing extra spaces * pylint fixes --- salt/modules/snapper.py | 159 +++++++++++++++++++++++++++++++++++-- tests/unit/modules/snapper_test.py | 50 ++++++++++++ 2 files changed, 201 insertions(+), 8 deletions(-) diff --git a/salt/modules/snapper.py b/salt/modules/snapper.py index 318ce9b99d..d5f1181743 100644 --- a/salt/modules/snapper.py +++ b/salt/modules/snapper.py @@ -290,6 +290,60 @@ def get_config(name='root'): ) +def create_config(name=None, + subvolume=None, + fstype=None, + template=None, + extra_opts=None): + ''' + Creates a new Snapper configuration + + name + Name of the new Snapper configuration. + subvolume + Path to the related subvolume. + fstype + Filesystem type of the subvolume. + template + Configuration template to use. (Default: default) + extra_opts + Extra Snapper configuration opts dictionary. It will override the values provided + by the given template (if any). + + CLI example: + + .. code-block:: bash + + salt '*' snapper.create_config name=myconfig subvolume=/foo/bar/ fstype=btrfs + salt '*' snapper.create_config name=myconfig subvolume=/foo/bar/ fstype=btrfs template="default" + salt '*' snapper.create_config name=myconfig subvolume=/foo/bar/ fstype=btrfs extra_opts='{"NUMBER_CLEANUP": False}' + ''' + def raise_arg_error(argname): + raise CommandExecutionError( + 'You must provide a "{0}" for the new configuration'.format(argname) + ) + + if not name: + raise_arg_error("name") + if not subvolume: + raise_arg_error("subvolume") + if not fstype: + raise_arg_error("fstype") + if not template: + template = "" + + try: + snapper.CreateConfig(name, subvolume, fstype, template) + if extra_opts: + set_config(name, **extra_opts) + return get_config(name) + except dbus.DBusException as exc: + raise CommandExecutionError( + 'Error encountered while creating the new configuration: {0}' + .format(_dbus_exception_to_reason(exc, locals())) + ) + + def create_snapshot(config='root', snapshot_type='single', pre_number=None, description=None, cleanup_algorithm='number', userdata=None, **kwargs): @@ -309,14 +363,14 @@ def create_snapshot(config='root', snapshot_type='single', pre_number=None, cleanup_algorithm Set the cleanup algorithm for the snapshot. - number - Deletes old snapshots when a certain number of snapshots - is reached. - timeline - Deletes old snapshots but keeps a number of hourly, - daily, weekly, monthly and yearly snapshots. - empty-pre-post - Deletes pre/post snapshot pairs with empty diffs. + number + Deletes old snapshots when a certain number of snapshots + is reached. + timeline + Deletes old snapshots but keeps a number of hourly, + daily, weekly, monthly and yearly snapshots. + empty-pre-post + Deletes pre/post snapshot pairs with empty diffs. userdata Set userdata for the snapshot (key-value pairs). @@ -364,6 +418,95 @@ def create_snapshot(config='root', snapshot_type='single', pre_number=None, return new_nr +def delete_snapshot(snapshots_ids=None, config="root"): + ''' + Deletes an snapshot + + config + Configuration name. (Default: root) + + snapshots_ids + List of the snapshots IDs to be deleted. + + CLI example: + + .. code-block:: bash + + salt '*' snapper.delete_snapshot 54 + salt '*' snapper.delete_snapshot config=root 54 + salt '*' snapper.delete_snapshot config=root snapshots_ids=[54,55,56] + ''' + if not snapshots_ids: + raise CommandExecutionError('Error: No snapshot ID has been provided') + try: + current_snapshots_ids = [x['id'] for x in list_snapshots(config)] + if not isinstance(snapshots_ids, list): + snapshots_ids = [snapshots_ids] + if not set(snapshots_ids).issubset(set(current_snapshots_ids)): + raise CommandExecutionError( + "Error: Snapshots '{0}' not found".format(", ".join( + [str(x) for x in set(snapshots_ids).difference( + set(current_snapshots_ids))])) + ) + snapper.DeleteSnapshots(config, snapshots_ids) + return {config: {"ids": snapshots_ids, "status": "deleted"}} + except dbus.DBusException as exc: + raise CommandExecutionError(_dbus_exception_to_reason(exc, locals())) + + +def modify_snapshot(snapshot_id=None, + description=None, + userdata=None, + cleanup=None, + config="root"): + ''' + Modify attributes of an existing snapshot. + + config + Configuration name. (Default: root) + + snapshot_id + ID of the snapshot to be modified. + + cleanup + Change the cleanup method of the snapshot. (str) + + description + Change the description of the snapshot. (str) + + userdata + Change the userdata dictionary of the snapshot. (dict) + + CLI example: + + .. code-block:: bash + + salt '*' snapper.modify_snapshot 54 description="my snapshot description" + salt '*' snapper.modify_snapshot 54 description="my snapshot description" + salt '*' snapper.modify_snapshot 54 userdata='{"foo": "bar"}' + salt '*' snapper.modify_snapshot snapshot_id=54 cleanup="number" + ''' + if not snapshot_id: + raise CommandExecutionError('Error: No snapshot ID has been provided') + + snapshot = get_snapshot(config=config, number=snapshot_id) + try: + # Updating only the explicitely provided attributes by the user + updated_opts = { + 'description': description if description is not None else snapshot['description'], + 'cleanup': cleanup if cleanup is not None else snapshot['cleanup'], + 'userdata': userdata if userdata is not None else snapshot['userdata'], + } + snapper.SetSnapshot(config, + snapshot_id, + updated_opts['description'], + updated_opts['cleanup'], + updated_opts['userdata']) + return get_snapshot(config=config, number=snapshot_id) + except dbus.DBusException as exc: + raise CommandExecutionError(_dbus_exception_to_reason(exc, locals())) + + def _get_num_interval(config, num_pre, num_post): ''' Returns numerical interval based on optionals num_pre, num_post values diff --git a/tests/unit/modules/snapper_test.py b/tests/unit/modules/snapper_test.py index ca985cfd05..a5d9b7686e 100644 --- a/tests/unit/modules/snapper_test.py +++ b/tests/unit/modules/snapper_test.py @@ -202,6 +202,26 @@ class SnapperTestCase(TestCase): self.assertEqual(snapper.status_to_string(128), ["extended attributes changed"]) self.assertEqual(snapper.status_to_string(256), ["ACL info changed"]) + @patch('salt.modules.snapper.snapper.CreateConfig', MagicMock()) + @patch('salt.modules.snapper.snapper.GetConfig', MagicMock(return_value=DBUS_RET['ListConfigs'][0])) + def test_create_config(self): + opts = { + 'name': 'testconfig', + 'subvolume': '/foo/bar/', + 'fstype': 'btrfs', + 'template': 'mytemplate', + 'extra_opts': {"NUMBER_CLEANUP": False}, + } + with patch('salt.modules.snapper.set_config', MagicMock()) as set_config_mock: + self.assertEqual(snapper.create_config(**opts), DBUS_RET['ListConfigs'][0]) + set_config_mock.assert_called_with("testconfig", **opts['extra_opts']) + + with patch('salt.modules.snapper.set_config', MagicMock()) as set_config_mock: + del opts['extra_opts'] + self.assertEqual(snapper.create_config(**opts), DBUS_RET['ListConfigs'][0]) + assert not set_config_mock.called + self.assertRaises(CommandExecutionError, snapper.create_config) + @patch('salt.modules.snapper.snapper.CreateSingleSnapshot', MagicMock(return_value=1234)) @patch('salt.modules.snapper.snapper.CreatePreSnapshot', MagicMock(return_value=1234)) @patch('salt.modules.snapper.snapper.CreatePostSnapshot', MagicMock(return_value=1234)) @@ -216,6 +236,36 @@ class SnapperTestCase(TestCase): } self.assertEqual(snapper.create_snapshot(**opts), 1234) + @patch('salt.modules.snapper.snapper.DeleteSnapshots', MagicMock()) + @patch('salt.modules.snapper.snapper.ListSnapshots', MagicMock(return_value=DBUS_RET['ListSnapshots'])) + def test_delete_snapshot_id_success(self): + self.assertEqual(snapper.delete_snapshot(snapshots_ids=43), {"root": {"ids": [43], "status": "deleted"}}) + self.assertEqual(snapper.delete_snapshot(snapshots_ids=[42, 43]), {"root": {"ids": [42, 43], "status": "deleted"}}) + + @patch('salt.modules.snapper.snapper.DeleteSnapshots', MagicMock()) + @patch('salt.modules.snapper.snapper.ListSnapshots', MagicMock(return_value=DBUS_RET['ListSnapshots'])) + def test_delete_snapshot_id_fail(self): + self.assertRaises(CommandExecutionError, snapper.delete_snapshot) + self.assertRaises(CommandExecutionError, snapper.delete_snapshot, snapshots_ids=1) + self.assertRaises(CommandExecutionError, snapper.delete_snapshot, snapshots_ids=[1, 2]) + + @patch('salt.modules.snapper.snapper.SetSnapshot', MagicMock()) + def test_modify_snapshot(self): + _ret = { + 'userdata': {'userdata2': 'uservalue2'}, + 'description': 'UPDATED DESCRIPTION', 'timestamp': 1457006571, + 'cleanup': 'number', 'user': 'root', 'type': 'pre', 'id': 42 + } + _opts = { + 'config': 'root', + 'snapshot_id': 42, + 'cleanup': 'number', + 'description': 'UPDATED DESCRIPTION', + 'userdata': {'userdata2': 'uservalue2'}, + } + with patch('salt.modules.snapper.get_snapshot', MagicMock(side_effect=[DBUS_RET['ListSnapshots'][0], _ret])): + self.assertDictEqual(snapper.modify_snapshot(**_opts), _ret) + @patch('salt.modules.snapper._get_last_snapshot', MagicMock(return_value={'id': 42})) def test__get_num_interval(self): self.assertEqual(snapper._get_num_interval(config=None, num_pre=None, num_post=None), (42, 0)) # pylint: disable=protected-access -- 2.11.0