Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
SUSE:SLE-12-SP5:Update
salt.4663
0046-Snapper-module-improvements.patch
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File 0046-Snapper-module-improvements.patch of Package salt.4663
From 1ba57479b4ba5db038817b0f0387c246204b2b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Su=C3=A1rez=20Hern=C3=A1ndez?= <psuarezhernandez@suse.com> Date: Fri, 27 Jan 2017 17:07:25 +0000 Subject: [PATCH 46/46] 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 * Adds multiple SUBVOLUME support to the Snapper module * Only include diff in the state response if `include_diff` is True * Raises "CommandExecutionError" if snapper command fails * Updating and fixing the documentation * Removing posible double '/' from the file paths * Fixing Snapper unit tests for SUBVOLUME support * Fixes pre/post snapshot order to get the inverse status * Some fixes and pylint --- salt/modules/snapper.py | 201 +++++++++++++++++++++++++++++++++---- salt/states/snapper.py | 49 ++++++--- tests/unit/modules/snapper_test.py | 54 ++++++++++ 3 files changed, 270 insertions(+), 34 deletions(-) diff --git a/salt/modules/snapper.py b/salt/modules/snapper.py index edecd87..db39ae7 100644 --- a/salt/modules/snapper.py +++ b/salt/modules/snapper.py @@ -276,6 +276,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): @@ -295,14 +349,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). @@ -347,6 +401,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 @@ -463,8 +606,12 @@ def status(config='root', num_pre=None, num_post=None): snapper.CreateComparison(config, int(pre), int(post)) files = snapper.GetFiles(config, int(pre), int(post)) status_ret = {} + SUBVOLUME = list_configs()[config]['SUBVOLUME'] for file in files: - status_ret[file[0]] = {'status': status_to_string(file[1])} + # In case of SUBVOLUME is included in filepath we remove it + # to prevent from filepath starting with double '/' + _filepath = file[0][len(SUBVOLUME):] if file[0].startswith(SUBVOLUME) else file[0] + status_ret[os.path.normpath(SUBVOLUME + _filepath)] = {'status': status_to_string(file[1])} return status_ret except dbus.DBusException as exc: raise CommandExecutionError( @@ -520,14 +667,19 @@ def undo(config='root', files=None, num_pre=None, num_post=None): 'Given file list contains files that are not present' 'in the changed filelist: {0}'.format(changed - requested)) - cmdret = __salt__['cmd.run']('snapper undochange {0}..{1} {2}'.format( - pre, post, ' '.join(requested))) - components = cmdret.split(' ') - ret = {} - for comp in components: - key, val = comp.split(':') - ret[key] = val - return ret + cmdret = __salt__['cmd.run']('snapper -c {0} undochange {1}..{2} {3}'.format( + config, pre, post, ' '.join(requested))) + + try: + components = cmdret.split(' ') + ret = {} + for comp in components: + key, val = comp.split(':') + ret[key] = val + return ret + except ValueError as exc: + raise CommandExecutionError( + 'Error while processing Snapper response: {0}'.format(cmdret)) def _get_jid_snapshots(jid, config='root'): @@ -601,13 +753,20 @@ def diff(config='root', filename=None, num_pre=None, num_post=None): if filename: files = [filename] if filename in files else [] - pre_mount = snapper.MountSnapshot(config, pre, False) if pre else "" - post_mount = snapper.MountSnapshot(config, post, False) if post else "" + SUBVOLUME = list_configs()[config]['SUBVOLUME'] + pre_mount = snapper.MountSnapshot(config, pre, False) if pre else SUBVOLUME + post_mount = snapper.MountSnapshot(config, post, False) if post else SUBVOLUME files_diff = dict() for filepath in [filepath for filepath in files if not os.path.isdir(filepath)]: - pre_file = pre_mount + filepath - post_file = post_mount + filepath + + _filepath = filepath + if filepath.startswith(SUBVOLUME): + _filepath = filepath[len(SUBVOLUME):] + + # Just in case, removing posible double '/' from the final file paths + pre_file = os.path.normpath(pre_mount + "/" + _filepath).replace("//", "/") + post_file = os.path.normpath(post_mount + "/" + _filepath).replace("//", "/") if os.path.isfile(pre_file): pre_file_exists = True diff --git a/salt/states/snapper.py b/salt/states/snapper.py index 2711550..be50bc4 100644 --- a/salt/states/snapper.py +++ b/salt/states/snapper.py @@ -23,7 +23,7 @@ The snapper state module allows you to manage state implicitly, in addition to explicit rules, in order to define a baseline and iterate with explicit rules as they show that they work in production. -The workflow is: once you have a workin and audited system, you would create +The workflow is: once you have a working and audited system, you would create your baseline snapshot (eg. with ``salt tgt snapper.create_snapshot``) and define in your state this baseline using the identifier of the snapshot (in this case: 20): @@ -33,10 +33,20 @@ define in your state this baseline using the identifier of the snapshot my_baseline: snapper.baseline_snapshot: - number: 20 + - include_diff: False - ignore: - /var/log - /var/cache +Baseline snapshots can be also referenced by tag. Most recent baseline snapshot +is used in case of multiple snapshots with the same tag: + + my_baseline_external_storage: + snapper.baseline_snapshot: + - tag: my_custom_baseline_tag + - config: external + - ignore: + - /mnt/tmp_files/ If you have this state, and you haven't done changes to the system since the snapshot, and you add a user, the state will show you the changes (including @@ -107,25 +117,39 @@ def __virtual__(): return 'snapper' if 'snapper.diff' in __salt__ else False -def _get_baseline_from_tag(tag): +def _get_baseline_from_tag(config, tag): ''' Returns the last created baseline snapshot marked with `tag` ''' last_snapshot = None - for snapshot in __salt__['snapper.list_snapshots'](): + for snapshot in __salt__['snapper.list_snapshots'](config): if tag == snapshot['userdata'].get("baseline_tag"): if not last_snapshot or last_snapshot['timestamp'] < snapshot['timestamp']: last_snapshot = snapshot return last_snapshot -def baseline_snapshot(name, number=None, tag=None, config='root', ignore=None): +def baseline_snapshot(name, number=None, tag=None, include_diff=True, config='root', ignore=None): ''' Enforces that no file is modified comparing against a previously defined snapshot identified by number. + number + Number of selected baseline snapshot. + + tag + Tag of the selected baseline snapshot. Most recent baseline baseline + snapshot is used in case of multiple snapshots with the same tag. + (`tag` and `number` cannot be used at the same time) + + include_diff + Include a diff in the response (Default: True) + + config + Snapper config name (Default: root) + ignore - List of files to ignore + List of files to ignore. (Default: None) ''' if not ignore: ignore = [] @@ -146,7 +170,7 @@ def baseline_snapshot(name, number=None, tag=None, config='root', ignore=None): return ret if tag: - snapshot = _get_baseline_from_tag(tag) + snapshot = _get_baseline_from_tag(config, tag) if not snapshot: ret.update({'result': False, 'comment': 'Baseline tag "{0}" not found'.format(tag)}) @@ -154,7 +178,7 @@ def baseline_snapshot(name, number=None, tag=None, config='root', ignore=None): number = snapshot['id'] status = __salt__['snapper.status']( - config, num_pre=number, num_post=0) + config, num_pre=0, num_post=number) for target in ignore: if os.path.isfile(target): @@ -164,18 +188,17 @@ def baseline_snapshot(name, number=None, tag=None, config='root', ignore=None): status.pop(target_file, None) for file in status: - status[file]['actions'] = status[file].pop("status") - # Only include diff for modified files - if "modified" in status[file]['actions']: + if "modified" in status[file]["status"] and include_diff: + status[file].pop("status") status[file].update(__salt__['snapper.diff'](config, num_pre=0, num_post=number, - filename=file)[file]) + filename=file).get(file, {})) if __opts__['test'] and status: - ret['pchanges'] = ret["changes"] - ret['changes'] = {} + ret['pchanges'] = status + ret['changes'] = ret['pchanges'] ret['comment'] = "{0} files changes are set to be undone".format(len(status.keys())) ret['result'] = None elif __opts__['test'] and not status: diff --git a/tests/unit/modules/snapper_test.py b/tests/unit/modules/snapper_test.py index 43f8898..a5d9b76 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 @@ -234,6 +284,7 @@ class SnapperTestCase(TestCase): @patch('salt.modules.snapper._get_num_interval', MagicMock(return_value=(42, 43))) @patch('salt.modules.snapper.snapper.GetComparison', MagicMock()) @patch('salt.modules.snapper.snapper.GetFiles', MagicMock(return_value=DBUS_RET['GetFiles'])) + @patch('salt.modules.snapper.snapper.ListConfigs', MagicMock(return_value=DBUS_RET['ListConfigs'])) def test_status(self): if six.PY3: self.assertCountEqual(snapper.status(), MODULE_RET['GETFILES']) @@ -288,6 +339,7 @@ class SnapperTestCase(TestCase): @patch('salt.modules.snapper._is_text_file', MagicMock(return_value=True)) @patch('os.path.isfile', MagicMock(side_effect=[False, True])) @patch('salt.utils.fopen', mock_open(read_data=FILE_CONTENT["/tmp/foo2"]['post'])) + @patch('salt.modules.snapper.snapper.ListConfigs', MagicMock(return_value=DBUS_RET['ListConfigs'])) def test_diff_text_file(self): if sys.version_info < (2, 7): self.assertEqual(snapper.diff(), {"/tmp/foo2": MODULE_RET['DIFF']['/tmp/foo26']}) @@ -302,6 +354,7 @@ class SnapperTestCase(TestCase): @patch('salt.modules.snapper._is_text_file', MagicMock(return_value=True)) @patch('os.path.isfile', MagicMock(side_effect=[True, True, False, True])) @patch('os.path.isdir', MagicMock(return_value=False)) + @patch('salt.modules.snapper.snapper.ListConfigs', MagicMock(return_value=DBUS_RET['ListConfigs'])) @skipIf(sys.version_info < (2, 7), 'Python 2.7 required to compare diff properly') def test_diff_text_files(self): fopen_effect = [ @@ -331,6 +384,7 @@ class SnapperTestCase(TestCase): "f18f971f1517449208a66589085ddd3723f7f6cefb56c141e3d97ae49e1d87fa", ]) }) + @patch('salt.modules.snapper.snapper.ListConfigs', MagicMock(return_value=DBUS_RET['ListConfigs'])) def test_diff_binary_files(self): fopen_effect = [ mock_open(read_data="dummy binary").return_value, -- 2.10.1
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor