14ef6d2b51
- splitting out the susemanager integration plugins into their own subpackages. ATM this only contains the zypp plugin to tell susemanager about manually installed packages. - Unit and integration tests fixes for 2016.3.2 Add: * 0018-Unit-tests-fixes-for-2016.3.2.patch * 0019-Fix-snapper_test-for-python26.patch * 0020-Integration-tests-fixes-for-2016.3.2.patch - Prevent pkg.install failure for expired keys (bsc#996455) Add: * 0017-Check-for-single-quote-before-splitting-on-single-qu.patch - Required D-Bus and generating machine ID where it is missing - Fix sphinx crashes when documentation is being generated Add script for documentation update. Add: * 0016-Improve-Mock-to-be-flexible-and-able-to-mock-methods.patch * update-documentation.sh - Fix pkg.installed refresh repo failure (bsc#993549) Fix salt.states.pkgrepo.management no change failure (bsc#990440) Add: * 0014-Add-ignore_repo_failure-option-to-suppress-zypper-s-.patch * 0015-Remove-zypper-s-raise-exception-if-mod_repo-has-no-a.patch - Deprecate status.uptime one version later Add: OBS-URL: https://build.opensuse.org/request/show/430691 OBS-URL: https://build.opensuse.org/package/show/systemsmanagement:saltstack/salt?expand=0&rev=77
1279 lines
45 KiB
Diff
1279 lines
45 KiB
Diff
From 501eda9c64b6e8b4b66c0bf98592331248742ea0 Mon Sep 17 00:00:00 2001
|
|
From: Pablo Suarez Hernandez <psuarezhernandez@suse.com>
|
|
Date: Mon, 4 Jul 2016 16:26:33 +0100
|
|
Subject: [PATCH 10/12] snapper execution module
|
|
|
|
snapper state module
|
|
|
|
snapper module unit tests
|
|
|
|
some pylint fixes
|
|
|
|
more unit tests
|
|
|
|
Fix for snapper.diff when files are created or deleted
|
|
|
|
fix diff unit test while creating text file
|
|
|
|
passing *args and **kwargs to function when snapper.run
|
|
|
|
unit test for snapper.diff with binary file
|
|
|
|
load snapper state only if snapper module is loaded
|
|
|
|
Fix for _get_jid_snapshots if snapshots doesn't exist
|
|
|
|
pylint fixes
|
|
|
|
pylint: some fixes
|
|
|
|
Variable renaming. Pylint fixes
|
|
|
|
Fix in inline comments
|
|
|
|
Fix for pylint: W1699
|
|
|
|
some fixes and comments improvement
|
|
|
|
Prevent module failing if Snapper does not exist in D-Bus
|
|
|
|
Added function for baseline creation
|
|
|
|
Allow tag reference for baseline_snapshot state
|
|
---
|
|
salt/modules/snapper.py | 687 +++++++++++++++++++++++++++++++++++++
|
|
salt/states/snapper.py | 195 +++++++++++
|
|
tests/unit/modules/snapper_test.py | 324 +++++++++++++++++
|
|
3 files changed, 1206 insertions(+)
|
|
create mode 100644 salt/modules/snapper.py
|
|
create mode 100644 salt/states/snapper.py
|
|
create mode 100644 tests/unit/modules/snapper_test.py
|
|
|
|
diff --git a/salt/modules/snapper.py b/salt/modules/snapper.py
|
|
new file mode 100644
|
|
index 0000000..9a73820
|
|
--- /dev/null
|
|
+++ b/salt/modules/snapper.py
|
|
@@ -0,0 +1,687 @@
|
|
+# -*- coding: utf-8 -*-
|
|
+'''
|
|
+Module to manage filesystem snapshots with snapper
|
|
+
|
|
+:codeauthor: Duncan Mac-Vicar P. <dmacvicar@suse.de>
|
|
+:codeauthor: Pablo Suárez Hernández <psuarezhernandez@suse.de>
|
|
+
|
|
+:depends: ``dbus`` Python module.
|
|
+:depends: ``snapper`` http://snapper.io, available in most distros
|
|
+:maturity: new
|
|
+:platform: Linux
|
|
+'''
|
|
+
|
|
+from __future__ import absolute_import
|
|
+
|
|
+import logging
|
|
+import os
|
|
+import time
|
|
+import difflib
|
|
+from pwd import getpwuid
|
|
+
|
|
+from salt.exceptions import CommandExecutionError
|
|
+import salt.utils
|
|
+
|
|
+
|
|
+try:
|
|
+ import dbus # pylint: disable=wrong-import-order
|
|
+ HAS_DBUS = True
|
|
+except ImportError:
|
|
+ HAS_DBUS = False
|
|
+
|
|
+
|
|
+DBUS_STATUS_MAP = {
|
|
+ 1: "created",
|
|
+ 2: "deleted",
|
|
+ 4: "type changed",
|
|
+ 8: "modified",
|
|
+ 16: "permission changed",
|
|
+ 32: "owner changed",
|
|
+ 64: "group changed",
|
|
+ 128: "extended attributes changed",
|
|
+ 256: "ACL info changed",
|
|
+}
|
|
+
|
|
+SNAPPER_DBUS_OBJECT = 'org.opensuse.Snapper'
|
|
+SNAPPER_DBUS_PATH = '/org/opensuse/Snapper'
|
|
+SNAPPER_DBUS_INTERFACE = 'org.opensuse.Snapper'
|
|
+
|
|
+log = logging.getLogger(__name__) # pylint: disable=invalid-name
|
|
+
|
|
+bus = None # pylint: disable=invalid-name
|
|
+snapper = None # pylint: disable=invalid-name
|
|
+
|
|
+if HAS_DBUS:
|
|
+ bus = dbus.SystemBus() # pylint: disable=invalid-name
|
|
+ if SNAPPER_DBUS_OBJECT in bus.list_activatable_names():
|
|
+ snapper = dbus.Interface(bus.get_object(SNAPPER_DBUS_OBJECT, # pylint: disable=invalid-name
|
|
+ SNAPPER_DBUS_PATH),
|
|
+ dbus_interface=SNAPPER_DBUS_INTERFACE)
|
|
+
|
|
+
|
|
+def __virtual__():
|
|
+ if not HAS_DBUS:
|
|
+ return (False, 'The snapper module cannot be loaded:'
|
|
+ ' missing python dbus module')
|
|
+ elif not snapper:
|
|
+ return (False, 'The snapper module cannot be loaded:'
|
|
+ ' missing snapper')
|
|
+ return 'snapper'
|
|
+
|
|
+
|
|
+def _snapshot_to_data(snapshot):
|
|
+ '''
|
|
+ Returns snapshot data from a D-Bus response.
|
|
+
|
|
+ A snapshot D-Bus response is a dbus.Struct containing the
|
|
+ information related to a snapshot:
|
|
+
|
|
+ [id, type, pre_snapshot, timestamp, user, description,
|
|
+ cleanup_algorithm, userdata]
|
|
+
|
|
+ id: dbus.UInt32
|
|
+ type: dbus.UInt16
|
|
+ pre_snapshot: dbus.UInt32
|
|
+ timestamp: dbus.Int64
|
|
+ user: dbus.UInt32
|
|
+ description: dbus.String
|
|
+ cleaup_algorithm: dbus.String
|
|
+ userdata: dbus.Dictionary
|
|
+ '''
|
|
+ data = {}
|
|
+
|
|
+ data['id'] = snapshot[0]
|
|
+ data['type'] = ['single', 'pre', 'post'][snapshot[1]]
|
|
+ if data['type'] == 'post':
|
|
+ data['pre'] = snapshot[2]
|
|
+
|
|
+ if snapshot[3] != -1:
|
|
+ data['timestamp'] = snapshot[3]
|
|
+ else:
|
|
+ data['timestamp'] = int(time.time())
|
|
+
|
|
+ data['user'] = getpwuid(snapshot[4])[0]
|
|
+ data['description'] = snapshot[5]
|
|
+ data['cleanup'] = snapshot[6]
|
|
+
|
|
+ data['userdata'] = {}
|
|
+ for key, value in snapshot[7].items():
|
|
+ data['userdata'][key] = value
|
|
+
|
|
+ return data
|
|
+
|
|
+
|
|
+def _dbus_exception_to_reason(exc, args):
|
|
+ '''
|
|
+ Returns a error message from a snapper DBusException
|
|
+ '''
|
|
+ error = exc.get_dbus_name()
|
|
+ if error == 'error.unknown_config':
|
|
+ return "Unknown configuration '{0}'".format(args['config'])
|
|
+ elif error == 'error.illegal_snapshot':
|
|
+ return 'Invalid snapshot'
|
|
+ else:
|
|
+ return exc.get_dbus_name()
|
|
+
|
|
+
|
|
+def list_snapshots(config='root'):
|
|
+ '''
|
|
+ List available snapshots
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.list_snapshots config=myconfig
|
|
+ '''
|
|
+ try:
|
|
+ snapshots = snapper.ListSnapshots(config)
|
|
+ return [_snapshot_to_data(s) for s in snapshots]
|
|
+ except dbus.DBusException as exc:
|
|
+ raise CommandExecutionError(
|
|
+ 'Error encountered while listing snapshots: {0}'
|
|
+ .format(_dbus_exception_to_reason(exc, locals()))
|
|
+ )
|
|
+
|
|
+
|
|
+def get_snapshot(number=0, config='root'):
|
|
+ '''
|
|
+ Get detailed information about a given snapshot
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.get_snapshot 1
|
|
+ '''
|
|
+ try:
|
|
+ snapshot = snapper.GetSnapshot(config, int(number))
|
|
+ return _snapshot_to_data(snapshot)
|
|
+ except dbus.DBusException as exc:
|
|
+ raise CommandExecutionError(
|
|
+ 'Error encountered while retrieving snapshot: {0}'
|
|
+ .format(_dbus_exception_to_reason(exc, locals()))
|
|
+ )
|
|
+
|
|
+
|
|
+def list_configs():
|
|
+ '''
|
|
+ List all available configs
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.list_configs
|
|
+ '''
|
|
+ try:
|
|
+ configs = snapper.ListConfigs()
|
|
+ return dict((config[0], config[2]) for config in configs)
|
|
+ except dbus.DBusException as exc:
|
|
+ raise CommandExecutionError(
|
|
+ 'Error encountered while listing configurations: {0}'
|
|
+ .format(_dbus_exception_to_reason(exc, locals()))
|
|
+ )
|
|
+
|
|
+
|
|
+def _config_filter(value):
|
|
+ if isinstance(value, bool):
|
|
+ return 'yes' if value else 'no'
|
|
+ return value
|
|
+
|
|
+
|
|
+def set_config(name='root', **kwargs):
|
|
+ '''
|
|
+ Set configuration values
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.set_config SYNC_ACL=True
|
|
+
|
|
+ Keys are case insensitive as they will be always uppercased to
|
|
+ snapper convention. The above example is equivalent to:
|
|
+
|
|
+ .. code-block:: bash
|
|
+ salt '*' snapper.set_config sync_acl=True
|
|
+ '''
|
|
+ try:
|
|
+ data = dict((k.upper(), _config_filter(v)) for k, v in
|
|
+ kwargs.items() if not k.startswith('__'))
|
|
+ snapper.SetConfig(name, data)
|
|
+ except dbus.DBusException as exc:
|
|
+ raise CommandExecutionError(
|
|
+ 'Error encountered while setting configuration {0}: {1}'
|
|
+ .format(name, _dbus_exception_to_reason(exc, locals()))
|
|
+ )
|
|
+ return True
|
|
+
|
|
+
|
|
+def _get_last_snapshot(config='root'):
|
|
+ '''
|
|
+ Returns the last existing created snapshot
|
|
+ '''
|
|
+ snapshot_list = sorted(list_snapshots(config), key=lambda x: x['id'])
|
|
+ return snapshot_list[-1]
|
|
+
|
|
+
|
|
+def status_to_string(dbus_status):
|
|
+ '''
|
|
+ Converts a numeric dbus snapper status into a string
|
|
+ '''
|
|
+ status_tuple = (
|
|
+ dbus_status & 0b000000001, dbus_status & 0b000000010, dbus_status & 0b000000100,
|
|
+ dbus_status & 0b000001000, dbus_status & 0b000010000, dbus_status & 0b000100000,
|
|
+ dbus_status & 0b001000000, dbus_status & 0b010000000, dbus_status & 0b100000000
|
|
+ )
|
|
+
|
|
+ return [DBUS_STATUS_MAP[status] for status in status_tuple if status]
|
|
+
|
|
+
|
|
+def get_config(name='root'):
|
|
+ '''
|
|
+ Retrieves all values from a given configuration
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.get_config
|
|
+ '''
|
|
+ try:
|
|
+ config = snapper.GetConfig(name)
|
|
+ return config
|
|
+ except dbus.DBusException as exc:
|
|
+ raise CommandExecutionError(
|
|
+ 'Error encountered while retrieving 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):
|
|
+ '''
|
|
+ Creates an snapshot
|
|
+
|
|
+ config
|
|
+ Configuration name.
|
|
+ snapshot_type
|
|
+ Specifies the type of the new snapshot. Possible values are
|
|
+ single, pre and post.
|
|
+ pre_number
|
|
+ For post snapshots the number of the pre snapshot must be
|
|
+ provided.
|
|
+ description
|
|
+ Description for the snapshot. If not given, the salt job will be used.
|
|
+ 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.
|
|
+ userdata
|
|
+ Set userdata for the snapshot (key-value pairs).
|
|
+
|
|
+ Returns the number of the created snapshot.
|
|
+
|
|
+ .. code-block:: bash
|
|
+ salt '*' snapper.create_snapshot
|
|
+ '''
|
|
+ if not userdata:
|
|
+ userdata = {}
|
|
+
|
|
+ jid = kwargs.get('__pub_jid')
|
|
+ if description is None and jid is not None:
|
|
+ description = 'salt job {0}'.format(jid)
|
|
+
|
|
+ if jid is not None:
|
|
+ userdata['salt_jid'] = jid
|
|
+
|
|
+ new_nr = None
|
|
+ try:
|
|
+ if snapshot_type == 'single':
|
|
+ new_nr = snapper.CreateSingleSnapshot(config, description,
|
|
+ cleanup_algorithm, userdata)
|
|
+ elif snapshot_type == 'pre':
|
|
+ new_nr = snapper.CreatePreSnapshot(config, description,
|
|
+ cleanup_algorithm, userdata)
|
|
+ elif snapshot_type == 'post':
|
|
+ if pre_number is None:
|
|
+ raise CommandExecutionError(
|
|
+ "pre snapshot number 'pre_number' needs to be"
|
|
+ "specified for snapshots of the 'post' type")
|
|
+ new_nr = snapper.CreatePostSnapshot(config, pre_number, description,
|
|
+ cleanup_algorithm, userdata)
|
|
+ else:
|
|
+ raise CommandExecutionError(
|
|
+ "Invalid snapshot type '{0}'", format(snapshot_type))
|
|
+ except dbus.DBusException as exc:
|
|
+ raise CommandExecutionError(
|
|
+ 'Error encountered while listing changed files: {0}'
|
|
+ .format(_dbus_exception_to_reason(exc, locals()))
|
|
+ )
|
|
+ return new_nr
|
|
+
|
|
+
|
|
+def _get_num_interval(config, num_pre, num_post):
|
|
+ '''
|
|
+ Returns numerical interval based on optionals num_pre, num_post values
|
|
+ '''
|
|
+ post = int(num_post) if num_post else 0
|
|
+ pre = int(num_pre) if num_pre is not None else _get_last_snapshot(config)['id']
|
|
+ return pre, post
|
|
+
|
|
+
|
|
+def _is_text_file(filename):
|
|
+ '''
|
|
+ Checks if a file is a text file
|
|
+ '''
|
|
+ type_of_file = os.popen('file -bi {0}'.format(filename), 'r').read()
|
|
+ return type_of_file.startswith('text')
|
|
+
|
|
+
|
|
+def run(function, *args, **kwargs):
|
|
+ '''
|
|
+ Runs a function from an execution module creating pre and post snapshots
|
|
+ and associating the salt job id with those snapshots for easy undo and
|
|
+ cleanup.
|
|
+
|
|
+ function
|
|
+ Salt function to call.
|
|
+
|
|
+ config
|
|
+ Configuration name. (default: "root")
|
|
+
|
|
+ description
|
|
+ A description for the snapshots. (default: None)
|
|
+
|
|
+ userdata
|
|
+ Data to include in the snapshot metadata. (default: None)
|
|
+
|
|
+ cleanup_algorithm
|
|
+ Snapper cleanup algorithm. (default: "number")
|
|
+
|
|
+ `*args`
|
|
+ args for the function to call. (default: None)
|
|
+
|
|
+ `**kwargs`
|
|
+ kwargs for the function to call (default: None)
|
|
+
|
|
+ .. code-block:: bash
|
|
+ salt '*' snapper.run file.append args='["/etc/motd", "some text"]'
|
|
+
|
|
+ This would run append text to /etc/motd using the file.append
|
|
+ module, and will create two snapshots, pre and post with the associated
|
|
+ metadata. The jid will be available as salt_jid in the userdata of the
|
|
+ snapshot.
|
|
+
|
|
+ You can immediately see the changes
|
|
+ '''
|
|
+ config = kwargs.pop("config", "root")
|
|
+ description = kwargs.pop("description", "snapper.run[{0}]".format(function))
|
|
+ cleanup_algorithm = kwargs.pop("cleanup_algorithm", "number")
|
|
+ userdata = kwargs.pop("userdata", {})
|
|
+
|
|
+ func_kwargs = dict((k, v) for k, v in kwargs.items() if not k.startswith('__'))
|
|
+ kwargs = dict((k, v) for k, v in kwargs.items() if k.startswith('__'))
|
|
+
|
|
+ pre_nr = __salt__['snapper.create_snapshot'](
|
|
+ config=config,
|
|
+ snapshot_type='pre',
|
|
+ description=description,
|
|
+ cleanup_algorithm=cleanup_algorithm,
|
|
+ userdata=userdata,
|
|
+ **kwargs)
|
|
+
|
|
+ if function not in __salt__:
|
|
+ raise CommandExecutionError(
|
|
+ 'function "{0}" does not exist'.format(function)
|
|
+ )
|
|
+
|
|
+ try:
|
|
+ ret = __salt__[function](*args, **func_kwargs)
|
|
+ except CommandExecutionError as exc:
|
|
+ ret = "\n".join([str(exc), __salt__[function].__doc__])
|
|
+
|
|
+ __salt__['snapper.create_snapshot'](
|
|
+ config=config,
|
|
+ snapshot_type='post',
|
|
+ pre_number=pre_nr,
|
|
+ description=description,
|
|
+ cleanup_algorithm=cleanup_algorithm,
|
|
+ userdata=userdata,
|
|
+ **kwargs)
|
|
+ return ret
|
|
+
|
|
+
|
|
+def status(config='root', num_pre=None, num_post=None):
|
|
+ '''
|
|
+ Returns a comparison between two snapshots
|
|
+
|
|
+ config
|
|
+ Configuration name.
|
|
+
|
|
+ num_pre
|
|
+ first snapshot ID to compare. Default is last snapshot
|
|
+
|
|
+ num_post
|
|
+ last snapshot ID to compare. Default is 0 (current state)
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.status
|
|
+ salt '*' snapper.status num_pre=19 num_post=20
|
|
+ '''
|
|
+ try:
|
|
+ pre, post = _get_num_interval(config, num_pre, num_post)
|
|
+ snapper.CreateComparison(config, int(pre), int(post))
|
|
+ files = snapper.GetFiles(config, int(pre), int(post))
|
|
+ status_ret = {}
|
|
+ for file in files:
|
|
+ status_ret[file[0]] = {'status': status_to_string(file[1])}
|
|
+ return status_ret
|
|
+ except dbus.DBusException as exc:
|
|
+ raise CommandExecutionError(
|
|
+ 'Error encountered while listing changed files: {0}'
|
|
+ .format(_dbus_exception_to_reason(exc, locals()))
|
|
+ )
|
|
+
|
|
+
|
|
+def changed_files(config='root', num_pre=None, num_post=None):
|
|
+ '''
|
|
+ Returns the files changed between two snapshots
|
|
+
|
|
+ config
|
|
+ Configuration name.
|
|
+
|
|
+ num_pre
|
|
+ first snapshot ID to compare. Default is last snapshot
|
|
+
|
|
+ num_post
|
|
+ last snapshot ID to compare. Default is 0 (current state)
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.changed_files
|
|
+ salt '*' snapper.changed_files num_pre=19 num_post=20
|
|
+ '''
|
|
+ return status(config, num_pre, num_post).keys()
|
|
+
|
|
+
|
|
+def undo(config='root', files=None, num_pre=None, num_post=None):
|
|
+ '''
|
|
+ Undo all file changes that happened between num_pre and num_post, leaving
|
|
+ the files into the state of num_pre.
|
|
+
|
|
+ .. warning::
|
|
+ If one of the files has changes after num_post, they will be overwriten
|
|
+ The snapshots are used to determine the file list, but the current
|
|
+ version of the files will be overwritten by the versions in num_pre.
|
|
+
|
|
+ You to undo changes between num_pre and the current version of the
|
|
+ files use num_post=0.
|
|
+ '''
|
|
+ pre, post = _get_num_interval(config, num_pre, num_post)
|
|
+
|
|
+ changes = status(config, pre, post)
|
|
+ changed = set(changes.keys())
|
|
+ requested = set(files or changed)
|
|
+
|
|
+ if not requested.issubset(changed):
|
|
+ raise CommandExecutionError(
|
|
+ '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
|
|
+
|
|
+
|
|
+def _get_jid_snapshots(jid, config='root'):
|
|
+ '''
|
|
+ Returns pre/post snapshots made by a given Salt jid
|
|
+
|
|
+ Looks for 'salt_jid' entries into snapshots userdata which are created
|
|
+ when 'snapper.run' is executed.
|
|
+ '''
|
|
+ jid_snapshots = [x for x in list_snapshots(config) if x['userdata'].get("salt_jid") == jid]
|
|
+ pre_snapshot = [x for x in jid_snapshots if x['type'] == "pre"]
|
|
+ post_snapshot = [x for x in jid_snapshots if x['type'] == "post"]
|
|
+
|
|
+ if not pre_snapshot or not post_snapshot:
|
|
+ raise CommandExecutionError("Jid '{0}' snapshots not found".format(jid))
|
|
+
|
|
+ return (
|
|
+ pre_snapshot[0]['id'],
|
|
+ post_snapshot[0]['id']
|
|
+ )
|
|
+
|
|
+
|
|
+def undo_jid(jid, config='root'):
|
|
+ '''
|
|
+ Undo the changes applied by a salt job
|
|
+
|
|
+ jid
|
|
+ The job id to lookup
|
|
+
|
|
+ config
|
|
+ Configuration name.
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.undo_jid jid=20160607130930720112
|
|
+ '''
|
|
+ pre_snapshot, post_snapshot = _get_jid_snapshots(jid, config=config)
|
|
+ return undo(config, num_pre=pre_snapshot, num_post=post_snapshot)
|
|
+
|
|
+
|
|
+def diff(config='root', filename=None, num_pre=None, num_post=None):
|
|
+ '''
|
|
+ Returns the differences between two snapshots
|
|
+
|
|
+ config
|
|
+ Configuration name.
|
|
+
|
|
+ filename
|
|
+ if not provided the showing differences between snapshots for
|
|
+ all "text" files
|
|
+
|
|
+ num_pre
|
|
+ first snapshot ID to compare. Default is last snapshot
|
|
+
|
|
+ num_post
|
|
+ last snapshot ID to compare. Default is 0 (current state)
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.diff
|
|
+ salt '*' snapper.diff filename=/var/log/snapper.log num_pre=19 num_post=20
|
|
+ '''
|
|
+ try:
|
|
+ pre, post = _get_num_interval(config, num_pre, num_post)
|
|
+
|
|
+ files = changed_files(config, pre, post)
|
|
+ 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 ""
|
|
+
|
|
+ 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
|
|
+
|
|
+ if os.path.isfile(pre_file):
|
|
+ pre_file_exists = True
|
|
+ pre_file_content = salt.utils.fopen(pre_file).readlines()
|
|
+ else:
|
|
+ pre_file_content = []
|
|
+ pre_file_exists = False
|
|
+
|
|
+ if os.path.isfile(post_file):
|
|
+ post_file_exists = True
|
|
+ post_file_content = salt.utils.fopen(post_file).readlines()
|
|
+ else:
|
|
+ post_file_content = []
|
|
+ post_file_exists = False
|
|
+
|
|
+ if _is_text_file(pre_file) or _is_text_file(post_file):
|
|
+ files_diff[filepath] = {
|
|
+ 'comment': "text file changed",
|
|
+ 'diff': ''.join(difflib.unified_diff(pre_file_content,
|
|
+ post_file_content,
|
|
+ fromfile=pre_file,
|
|
+ tofile=post_file))}
|
|
+
|
|
+ if pre_file_exists and not post_file_exists:
|
|
+ files_diff[filepath]['comment'] = "text file deleted"
|
|
+ if not pre_file_exists and post_file_exists:
|
|
+ files_diff[filepath]['comment'] = "text file created"
|
|
+
|
|
+ elif not _is_text_file(pre_file) and not _is_text_file(post_file):
|
|
+ # This is a binary file
|
|
+ files_diff[filepath] = {'comment': "binary file changed"}
|
|
+ if pre_file_exists:
|
|
+ files_diff[filepath]['old_sha256_digest'] = __salt__['hashutil.sha256_digest'](''.join(pre_file_content))
|
|
+ if post_file_exists:
|
|
+ files_diff[filepath]['new_sha256_digest'] = __salt__['hashutil.sha256_digest'](''.join(post_file_content))
|
|
+ if post_file_exists and not pre_file_exists:
|
|
+ files_diff[filepath]['comment'] = "binary file created"
|
|
+ if pre_file_exists and not post_file_exists:
|
|
+ files_diff[filepath]['comment'] = "binary file deleted"
|
|
+
|
|
+ if pre:
|
|
+ snapper.UmountSnapshot(config, pre, False)
|
|
+ if post:
|
|
+ snapper.UmountSnapshot(config, post, False)
|
|
+ return files_diff
|
|
+ except dbus.DBusException as exc:
|
|
+ raise CommandExecutionError(
|
|
+ 'Error encountered while showing differences between snapshots: {0}'
|
|
+ .format(_dbus_exception_to_reason(exc, locals()))
|
|
+ )
|
|
+
|
|
+
|
|
+def diff_jid(jid, config='root'):
|
|
+ '''
|
|
+ Returns the changes applied by a `jid`
|
|
+
|
|
+ jid
|
|
+ The job id to lookup
|
|
+
|
|
+ config
|
|
+ Configuration name.
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.diff_jid jid=20160607130930720112
|
|
+ '''
|
|
+ pre_snapshot, post_snapshot = _get_jid_snapshots(jid, config=config)
|
|
+ return diff(config, num_pre=pre_snapshot, num_post=post_snapshot)
|
|
+
|
|
+
|
|
+def create_baseline(tag="baseline", config='root'):
|
|
+ '''
|
|
+ Creates a snapshot marked as baseline
|
|
+
|
|
+ tag
|
|
+ Tag name for the baseline
|
|
+
|
|
+ config
|
|
+ Configuration name.
|
|
+
|
|
+ CLI example:
|
|
+
|
|
+ .. code-block:: bash
|
|
+
|
|
+ salt '*' snapper.create_baseline
|
|
+ salt '*' snapper.create_baseline my_custom_baseline
|
|
+ '''
|
|
+ return __salt__['snapper.create_snapshot'](config=config,
|
|
+ snapshot_type='single',
|
|
+ description="baseline snapshot",
|
|
+ cleanup_algorithm="number",
|
|
+ userdata={"baseline_tag": tag})
|
|
diff --git a/salt/states/snapper.py b/salt/states/snapper.py
|
|
new file mode 100644
|
|
index 0000000..2711550
|
|
--- /dev/null
|
|
+++ b/salt/states/snapper.py
|
|
@@ -0,0 +1,195 @@
|
|
+# -*- coding: utf-8 -*-
|
|
+'''
|
|
+Managing implicit state and baselines using snapshots
|
|
+=====================================================
|
|
+
|
|
+Salt can manage state against explicitly defined state, for example
|
|
+if your minion state is defined by:
|
|
+
|
|
+.. code-block:: yaml
|
|
+
|
|
+ /etc/config_file:
|
|
+ file.managed:
|
|
+ - source: salt://configs/myconfig
|
|
+
|
|
+If someone modifies this file, the next application of the highstate will
|
|
+allow the admin to correct this deviation and the file will be corrected.
|
|
+
|
|
+Now, what happens if somebody creates a file ``/etc/new_config_file`` and
|
|
+deletes ``/etc/important_config_file``? Unless you have a explicit rule, this
|
|
+change will go unnoticed.
|
|
+
|
|
+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
|
|
+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):
|
|
+
|
|
+.. code-block:: yaml
|
|
+
|
|
+ my_baseline:
|
|
+ snapper.baseline_snapshot:
|
|
+ - number: 20
|
|
+ - ignore:
|
|
+ - /var/log
|
|
+ - /var/cache
|
|
+
|
|
+
|
|
+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
|
|
+full diffs) to ``/etc/passwd``, ``/etc/shadow``, etc if you call it
|
|
+with ``test=True`` and will undo all changes if you call it without.
|
|
+
|
|
+This allows you to add more explicit state knowing that you are starting from a
|
|
+very well defined state, and that you can audit any change that is not part
|
|
+of your explicit configuration.
|
|
+
|
|
+So after you made this your state, you decided to introduce a change in your
|
|
+configuration:
|
|
+
|
|
+.. code-block:: yaml
|
|
+
|
|
+ my_baseline:
|
|
+ snapper.baseline_snapshot:
|
|
+ - number: 20
|
|
+ - ignore:
|
|
+ - /var/log
|
|
+ - /var/cache
|
|
+
|
|
+ hosts_entry:
|
|
+ file.blockreplace:
|
|
+ - name: /etc/hosts
|
|
+ - content: 'First line of content'
|
|
+ - append_if_not_found: True
|
|
+
|
|
+
|
|
+The change in ``/etc/hosts`` will be done after any other change that deviates
|
|
+from the specified snapshot are reverted. This could be for example,
|
|
+modifications to the ``/etc/passwd`` file or changes in the ``/etc/hosts``
|
|
+that could render your the ``hosts_entry`` rule void or dangerous.
|
|
+
|
|
+Once you take a new snapshot and you update the baseline snapshot number to
|
|
+include the change in ``/etc/hosts`` the ``hosts_entry`` rule will basically
|
|
+do nothing. You are free to leave it there for documentation, to ensure that
|
|
+the change is made in case the snapshot is wrong, but if you remove anything
|
|
+that comes after the ``snapper.baseline_snapshot`` as it will have no effect:
|
|
+ by the moment the state is evaluated, the baseline state was already applied
|
|
+and include this change.
|
|
+
|
|
+.. warning::
|
|
+ Make sure you specify the baseline state before other rules, otherwise
|
|
+ the baseline state will revert all changes if they are not present in
|
|
+ the snapshot.
|
|
+
|
|
+.. warning::
|
|
+ Do not specify more than one baseline rule as only the last one will
|
|
+ affect the result.
|
|
+
|
|
+:codeauthor: Duncan Mac-Vicar P. <dmacvicar@suse.de>
|
|
+:codeauthor: Pablo Suárez Hernández <psuarezhernandez@suse.de>
|
|
+
|
|
+:maturity: new
|
|
+:platform: Linux
|
|
+'''
|
|
+
|
|
+from __future__ import absolute_import
|
|
+
|
|
+import os
|
|
+
|
|
+
|
|
+def __virtual__():
|
|
+ '''
|
|
+ Only load if the snapper module is available in __salt__
|
|
+ '''
|
|
+ return 'snapper' if 'snapper.diff' in __salt__ else False
|
|
+
|
|
+
|
|
+def _get_baseline_from_tag(tag):
|
|
+ '''
|
|
+ Returns the last created baseline snapshot marked with `tag`
|
|
+ '''
|
|
+ last_snapshot = None
|
|
+ for snapshot in __salt__['snapper.list_snapshots']():
|
|
+ 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):
|
|
+ '''
|
|
+ Enforces that no file is modified comparing against a previously
|
|
+ defined snapshot identified by number.
|
|
+
|
|
+ ignore
|
|
+ List of files to ignore
|
|
+ '''
|
|
+ if not ignore:
|
|
+ ignore = []
|
|
+
|
|
+ ret = {'changes': {},
|
|
+ 'comment': '',
|
|
+ 'name': name,
|
|
+ 'result': True}
|
|
+
|
|
+ if number is None and tag is None:
|
|
+ ret.update({'result': False,
|
|
+ 'comment': 'Snapshot tag or number must be specified'})
|
|
+ return ret
|
|
+
|
|
+ if number and tag:
|
|
+ ret.update({'result': False,
|
|
+ 'comment': 'Cannot use snapshot tag and number at the same time'})
|
|
+ return ret
|
|
+
|
|
+ if tag:
|
|
+ snapshot = _get_baseline_from_tag(tag)
|
|
+ if not snapshot:
|
|
+ ret.update({'result': False,
|
|
+ 'comment': 'Baseline tag "{0}" not found'.format(tag)})
|
|
+ return ret
|
|
+ number = snapshot['id']
|
|
+
|
|
+ status = __salt__['snapper.status'](
|
|
+ config, num_pre=number, num_post=0)
|
|
+
|
|
+ for target in ignore:
|
|
+ if os.path.isfile(target):
|
|
+ status.pop(target, None)
|
|
+ elif os.path.isdir(target):
|
|
+ for target_file in [target_file for target_file in status.keys() if target_file.startswith(target)]:
|
|
+ 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']:
|
|
+ status[file].update(__salt__['snapper.diff'](config,
|
|
+ num_pre=0,
|
|
+ num_post=number,
|
|
+ filename=file)[file])
|
|
+
|
|
+ if __opts__['test'] and status:
|
|
+ ret['pchanges'] = ret["changes"]
|
|
+ ret['changes'] = {}
|
|
+ ret['comment'] = "{0} files changes are set to be undone".format(len(status.keys()))
|
|
+ ret['result'] = None
|
|
+ elif __opts__['test'] and not status:
|
|
+ ret['changes'] = {}
|
|
+ ret['comment'] = "Nothing to be done"
|
|
+ ret['result'] = True
|
|
+ elif not __opts__['test'] and status:
|
|
+ undo = __salt__['snapper.undo'](config, num_pre=number, num_post=0,
|
|
+ files=status.keys())
|
|
+ ret['changes']['sumary'] = undo
|
|
+ ret['changes']['files'] = status
|
|
+ ret['result'] = True
|
|
+ else:
|
|
+ ret['comment'] = "No changes were done"
|
|
+ ret['result'] = True
|
|
+
|
|
+ return ret
|
|
diff --git a/tests/unit/modules/snapper_test.py b/tests/unit/modules/snapper_test.py
|
|
new file mode 100644
|
|
index 0000000..f27b2ba
|
|
--- /dev/null
|
|
+++ b/tests/unit/modules/snapper_test.py
|
|
@@ -0,0 +1,324 @@
|
|
+# -*- coding: utf-8 -*-
|
|
+'''
|
|
+Unit tests for the Snapper module
|
|
+
|
|
+:codeauthor: Duncan Mac-Vicar P. <dmacvicar@suse.de>
|
|
+:codeauthor: Pablo Suárez Hernández <psuarezhernandez@suse.de>
|
|
+'''
|
|
+
|
|
+from __future__ import absolute_import
|
|
+
|
|
+from salttesting import TestCase
|
|
+from salttesting.mock import (
|
|
+ MagicMock,
|
|
+ patch,
|
|
+ mock_open,
|
|
+)
|
|
+
|
|
+from salt.exceptions import CommandExecutionError
|
|
+from salttesting.helpers import ensure_in_syspath
|
|
+ensure_in_syspath('../../')
|
|
+
|
|
+from salt.modules import snapper
|
|
+
|
|
+# Globals
|
|
+snapper.__salt__ = dict()
|
|
+
|
|
+DBUS_RET = {
|
|
+ 'ListSnapshots': [
|
|
+ [42, 1, 0, 1457006571,
|
|
+ 0, 'Some description', '',
|
|
+ {'userdata1': 'userval1', 'salt_jid': '20160607130930720112'}],
|
|
+ [43, 2, 42, 1457006572,
|
|
+ 0, 'Blah Blah', '',
|
|
+ {'userdata2': 'userval2', 'salt_jid': '20160607130930720112'}]
|
|
+ ],
|
|
+ 'ListConfigs': [
|
|
+ [u'root', u'/', {
|
|
+ u'SUBVOLUME': u'/', u'NUMBER_MIN_AGE': u'1800',
|
|
+ u'TIMELINE_LIMIT_YEARLY': u'4-10', u'NUMBER_LIMIT_IMPORTANT': u'10',
|
|
+ u'FSTYPE': u'btrfs', u'TIMELINE_LIMIT_MONTHLY': u'4-10',
|
|
+ u'ALLOW_GROUPS': u'', u'EMPTY_PRE_POST_MIN_AGE': u'1800',
|
|
+ u'EMPTY_PRE_POST_CLEANUP': u'yes', u'BACKGROUND_COMPARISON': u'yes',
|
|
+ u'TIMELINE_LIMIT_HOURLY': u'4-10', u'ALLOW_USERS': u'',
|
|
+ u'TIMELINE_LIMIT_WEEKLY': u'0', u'TIMELINE_CREATE': u'no',
|
|
+ u'NUMBER_CLEANUP': u'yes', u'TIMELINE_CLEANUP': u'yes',
|
|
+ u'SPACE_LIMIT': u'0.5', u'NUMBER_LIMIT': u'10',
|
|
+ u'TIMELINE_MIN_AGE': u'1800', u'TIMELINE_LIMIT_DAILY': u'4-10',
|
|
+ u'SYNC_ACL': u'no', u'QGROUP': u'1/0'}
|
|
+ ]
|
|
+ ],
|
|
+ 'GetFiles': [
|
|
+ ['/root/.viminfo', 8],
|
|
+ ['/tmp/foo', 52],
|
|
+ ['/tmp/foo2', 1],
|
|
+ ['/tmp/foo3', 2],
|
|
+ ['/var/log/snapper.log', 8],
|
|
+ ['/var/cache/salt/minion/extmods/modules/snapper.py', 8],
|
|
+ ['/var/cache/salt/minion/extmods/modules/snapper.pyc', 8],
|
|
+ ],
|
|
+}
|
|
+
|
|
+FILE_CONTENT = {
|
|
+ '/tmp/foo': {
|
|
+ "pre": "dummy text",
|
|
+ "post": "another foobar"
|
|
+ },
|
|
+ '/tmp/foo2': {
|
|
+ "post": "another foobar"
|
|
+ }
|
|
+}
|
|
+
|
|
+MODULE_RET = {
|
|
+ 'SNAPSHOTS': [
|
|
+ {
|
|
+ 'userdata': {'userdata1': 'userval1', 'salt_jid': '20160607130930720112'},
|
|
+ 'description': 'Some description', 'timestamp': 1457006571,
|
|
+ 'cleanup': '', 'user': 'root', 'type': 'pre', 'id': 42
|
|
+ },
|
|
+ {
|
|
+ 'pre': 42,
|
|
+ 'userdata': {'userdata2': 'userval2', 'salt_jid': '20160607130930720112'},
|
|
+ 'description': 'Blah Blah', 'timestamp': 1457006572,
|
|
+ 'cleanup': '', 'user': 'root', 'type': 'post', 'id': 43
|
|
+ }
|
|
+ ],
|
|
+ 'LISTCONFIGS': {
|
|
+ u'root': {
|
|
+ u'SUBVOLUME': u'/', u'NUMBER_MIN_AGE': u'1800',
|
|
+ u'TIMELINE_LIMIT_YEARLY': u'4-10', u'NUMBER_LIMIT_IMPORTANT': u'10',
|
|
+ u'FSTYPE': u'btrfs', u'TIMELINE_LIMIT_MONTHLY': u'4-10',
|
|
+ u'ALLOW_GROUPS': u'', u'EMPTY_PRE_POST_MIN_AGE': u'1800',
|
|
+ u'EMPTY_PRE_POST_CLEANUP': u'yes', u'BACKGROUND_COMPARISON': u'yes',
|
|
+ u'TIMELINE_LIMIT_HOURLY': u'4-10', u'ALLOW_USERS': u'',
|
|
+ u'TIMELINE_LIMIT_WEEKLY': u'0', u'TIMELINE_CREATE': u'no',
|
|
+ u'NUMBER_CLEANUP': u'yes', u'TIMELINE_CLEANUP': u'yes',
|
|
+ u'SPACE_LIMIT': u'0.5', u'NUMBER_LIMIT': u'10',
|
|
+ u'TIMELINE_MIN_AGE': u'1800', u'TIMELINE_LIMIT_DAILY': u'4-10',
|
|
+ u'SYNC_ACL': u'no', u'QGROUP': u'1/0'
|
|
+ }
|
|
+ },
|
|
+ 'GETFILES': {
|
|
+ '/root/.viminfo': {'status': ['modified']},
|
|
+ '/tmp/foo': {'status': ['type changed', 'permission changed', 'owner changed']},
|
|
+ '/tmp/foo2': {'status': ['created']},
|
|
+ '/tmp/foo3': {'status': ['deleted']},
|
|
+ '/var/log/snapper.log': {'status': ['modified']},
|
|
+ '/var/cache/salt/minion/extmods/modules/snapper.py': {'status': ['modified']},
|
|
+ '/var/cache/salt/minion/extmods/modules/snapper.pyc': {'status': ['modified']},
|
|
+ },
|
|
+ 'DIFF': {
|
|
+ '/tmp/foo': {
|
|
+ 'comment': 'text file changed',
|
|
+ 'diff': "--- /.snapshots/55/snapshot/tmp/foo\n"
|
|
+ "+++ /tmp/foo\n"
|
|
+ "@@ -1 +1 @@\n"
|
|
+ "-dummy text"
|
|
+ "+another foobar"
|
|
+ },
|
|
+ '/tmp/foo2': {
|
|
+ 'comment': 'text file created',
|
|
+ 'diff': "--- /.snapshots/55/snapshot/tmp/foo2\n"
|
|
+ "+++ /tmp/foo2\n"
|
|
+ "@@ -0,0 +1 @@\n"
|
|
+ "+another foobar",
|
|
+ },
|
|
+ '/tmp/foo3': {
|
|
+ 'comment': 'binary file changed',
|
|
+ 'old_sha256_digest': 'e61f8b762d83f3b4aeb3689564b0ffbe54fa731a69a1e208dc9440ce0f69d19b',
|
|
+ 'new_sha256_digest': 'f18f971f1517449208a66589085ddd3723f7f6cefb56c141e3d97ae49e1d87fa',
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+
|
|
+class SnapperTestCase(TestCase):
|
|
+ def setUp(self):
|
|
+ self.dbus_mock = MagicMock()
|
|
+ self.DBusExceptionMock = MagicMock() # pylint: disable=invalid-name
|
|
+ self.dbus_mock.configure_mock(DBusException=self.DBusExceptionMock)
|
|
+ snapper.dbus = self.dbus_mock
|
|
+ snapper.snapper = MagicMock()
|
|
+
|
|
+ def test__snapshot_to_data(self):
|
|
+ data = snapper._snapshot_to_data(DBUS_RET['ListSnapshots'][0]) # pylint: disable=protected-access
|
|
+ self.assertEqual(data['id'], 42)
|
|
+ self.assertNotIn('pre', data)
|
|
+ self.assertEqual(data['type'], 'pre')
|
|
+ self.assertEqual(data['user'], 'root')
|
|
+ self.assertEqual(data['timestamp'], 1457006571)
|
|
+ self.assertEqual(data['description'], 'Some description')
|
|
+ self.assertEqual(data['cleanup'], '')
|
|
+ self.assertEqual(data['userdata']['userdata1'], 'userval1')
|
|
+
|
|
+ @patch('salt.modules.snapper.snapper.ListSnapshots', MagicMock(return_value=DBUS_RET['ListSnapshots']))
|
|
+ def test_list_snapshots(self):
|
|
+ self.assertEqual(snapper.list_snapshots(), MODULE_RET["SNAPSHOTS"])
|
|
+
|
|
+ @patch('salt.modules.snapper.snapper.GetSnapshot', MagicMock(return_value=DBUS_RET['ListSnapshots'][0]))
|
|
+ def test_get_snapshot(self):
|
|
+ self.assertEqual(snapper.get_snapshot(), MODULE_RET["SNAPSHOTS"][0])
|
|
+ self.assertEqual(snapper.get_snapshot(number=42), MODULE_RET["SNAPSHOTS"][0])
|
|
+ self.assertNotEqual(snapper.get_snapshot(number=42), MODULE_RET["SNAPSHOTS"][1])
|
|
+
|
|
+ @patch('salt.modules.snapper.snapper.ListConfigs', MagicMock(return_value=DBUS_RET['ListConfigs']))
|
|
+ def test_list_configs(self):
|
|
+ self.assertEqual(snapper.list_configs(), MODULE_RET["LISTCONFIGS"])
|
|
+
|
|
+ @patch('salt.modules.snapper.snapper.GetConfig', MagicMock(return_value=DBUS_RET['ListConfigs'][0]))
|
|
+ def test_get_config(self):
|
|
+ self.assertEqual(snapper.get_config(), DBUS_RET["ListConfigs"][0])
|
|
+
|
|
+ @patch('salt.modules.snapper.snapper.SetConfig', MagicMock())
|
|
+ def test_set_config(self):
|
|
+ opts = {'sync_acl': True, 'dummy': False, 'foobar': 1234}
|
|
+ self.assertEqual(snapper.set_config(opts), True)
|
|
+
|
|
+ def test_status_to_string(self):
|
|
+ self.assertEqual(snapper.status_to_string(1), ["created"])
|
|
+ self.assertEqual(snapper.status_to_string(2), ["deleted"])
|
|
+ self.assertEqual(snapper.status_to_string(4), ["type changed"])
|
|
+ self.assertEqual(snapper.status_to_string(8), ["modified"])
|
|
+ self.assertEqual(snapper.status_to_string(16), ["permission changed"])
|
|
+ self.assertListEqual(snapper.status_to_string(24), ["modified", "permission changed"])
|
|
+ self.assertEqual(snapper.status_to_string(32), ["owner changed"])
|
|
+ self.assertEqual(snapper.status_to_string(64), ["group changed"])
|
|
+ self.assertListEqual(snapper.status_to_string(97), ["created", "owner changed", "group changed"])
|
|
+ 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.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))
|
|
+ def test_create_snapshot(self):
|
|
+ for snapshot_type in ['pre', 'post', 'single']:
|
|
+ opts = {
|
|
+ '__pub_jid': 20160607130930720112,
|
|
+ 'type': snapshot_type,
|
|
+ 'description': 'Test description',
|
|
+ 'cleanup_algorithm': 'number',
|
|
+ 'pre_number': 23,
|
|
+ }
|
|
+ self.assertEqual(snapper.create_snapshot(**opts), 1234)
|
|
+
|
|
+ @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
|
|
+ self.assertEqual(snapper._get_num_interval(config=None, num_pre=None, num_post=50), (42, 50)) # pylint: disable=protected-access
|
|
+ self.assertEqual(snapper._get_num_interval(config=None, num_pre=42, num_post=50), (42, 50)) # pylint: disable=protected-access
|
|
+
|
|
+ def test_run(self):
|
|
+ patch_dict = {
|
|
+ 'snapper.create_snapshot': MagicMock(return_value=43),
|
|
+ 'test.ping': MagicMock(return_value=True),
|
|
+ }
|
|
+ with patch.dict(snapper.__salt__, patch_dict):
|
|
+ self.assertEqual(snapper.run("test.ping"), True)
|
|
+ self.assertRaises(CommandExecutionError, snapper.run, "unknown.func")
|
|
+
|
|
+ @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']))
|
|
+ def test_status(self):
|
|
+ self.assertItemsEqual(snapper.status(), MODULE_RET['GETFILES'])
|
|
+ self.assertItemsEqual(snapper.status(num_pre="42", num_post=43), MODULE_RET['GETFILES'])
|
|
+ self.assertItemsEqual(snapper.status(num_pre=42), MODULE_RET['GETFILES'])
|
|
+ self.assertItemsEqual(snapper.status(num_post=43), MODULE_RET['GETFILES'])
|
|
+
|
|
+ @patch('salt.modules.snapper.status', MagicMock(return_value=MODULE_RET['GETFILES']))
|
|
+ def test_changed_files(self):
|
|
+ self.assertEqual(snapper.changed_files(), MODULE_RET['GETFILES'].keys())
|
|
+
|
|
+ @patch('salt.modules.snapper._get_num_interval', MagicMock(return_value=(42, 43)))
|
|
+ @patch('salt.modules.snapper.status', MagicMock(return_value=MODULE_RET['GETFILES']))
|
|
+ def test_undo(self):
|
|
+ cmd_ret = 'create:0 modify:1 delete:0'
|
|
+ with patch.dict(snapper.__salt__, {'cmd.run': MagicMock(return_value=cmd_ret)}):
|
|
+ module_ret = {'create': '0', 'delete': '0', 'modify': '1'}
|
|
+ self.assertEqual(snapper.undo(files=['/tmp/foo']), module_ret)
|
|
+
|
|
+ cmd_ret = 'create:1 modify:1 delete:0'
|
|
+ with patch.dict(snapper.__salt__, {'cmd.run': MagicMock(return_value=cmd_ret)}):
|
|
+ module_ret = {'create': '1', 'delete': '0', 'modify': '1'}
|
|
+ self.assertEqual(snapper.undo(files=['/tmp/foo', '/tmp/foo2']), module_ret)
|
|
+
|
|
+ cmd_ret = 'create:1 modify:1 delete:1'
|
|
+ with patch.dict(snapper.__salt__, {'cmd.run': MagicMock(return_value=cmd_ret)}):
|
|
+ module_ret = {'create': '1', 'delete': '1', 'modify': '1'}
|
|
+ self.assertEqual(snapper.undo(files=['/tmp/foo', '/tmp/foo2', '/tmp/foo3']), module_ret)
|
|
+
|
|
+ @patch('salt.modules.snapper.list_snapshots', MagicMock(return_value=MODULE_RET['SNAPSHOTS']))
|
|
+ def test__get_jid_snapshots(self):
|
|
+ self.assertEqual(
|
|
+ snapper._get_jid_snapshots("20160607130930720112"), # pylint: disable=protected-access
|
|
+ (MODULE_RET['SNAPSHOTS'][0]['id'], MODULE_RET['SNAPSHOTS'][1]['id'])
|
|
+ )
|
|
+
|
|
+ @patch('salt.modules.snapper._get_jid_snapshots', MagicMock(return_value=(42, 43)))
|
|
+ @patch('salt.modules.snapper.undo', MagicMock(return_value='create:1 modify:1 delete:1'))
|
|
+ def test_undo_jid(self):
|
|
+ self.assertEqual(snapper.undo_jid(20160607130930720112), 'create:1 modify:1 delete:1')
|
|
+
|
|
+ @patch('salt.modules.snapper._get_num_interval', MagicMock(return_value=(42, 43)))
|
|
+ @patch('salt.modules.snapper.snapper.MountSnapshot', MagicMock(side_effect=["/.snapshots/55/snapshot", ""]))
|
|
+ @patch('salt.modules.snapper.snapper.UmountSnapshot', MagicMock(return_value=""))
|
|
+ @patch('os.path.isdir', MagicMock(return_value=False))
|
|
+ @patch('salt.modules.snapper.changed_files', MagicMock(return_value=["/tmp/foo2"]))
|
|
+ @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']))
|
|
+ def test_diff_text_file(self):
|
|
+ self.assertEqual(snapper.diff(), {"/tmp/foo2": MODULE_RET['DIFF']['/tmp/foo2']})
|
|
+
|
|
+ @patch('salt.modules.snapper._get_num_interval', MagicMock(return_value=(55, 0)))
|
|
+ @patch('salt.modules.snapper.snapper.MountSnapshot', MagicMock(
|
|
+ side_effect=["/.snapshots/55/snapshot", "", "/.snapshots/55/snapshot", ""]))
|
|
+ @patch('salt.modules.snapper.snapper.UmountSnapshot', MagicMock(return_value=""))
|
|
+ @patch('salt.modules.snapper.changed_files', MagicMock(return_value=["/tmp/foo", "/tmp/foo2"]))
|
|
+ @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))
|
|
+ def test_diff_text_files(self):
|
|
+ fopen_effect = [
|
|
+ mock_open(read_data=FILE_CONTENT["/tmp/foo"]['pre']).return_value,
|
|
+ mock_open(read_data=FILE_CONTENT["/tmp/foo"]['post']).return_value,
|
|
+ mock_open(read_data=FILE_CONTENT["/tmp/foo2"]['post']).return_value,
|
|
+ ]
|
|
+ with patch('salt.utils.fopen') as fopen_mock:
|
|
+ fopen_mock.side_effect = fopen_effect
|
|
+ module_ret = {
|
|
+ "/tmp/foo": MODULE_RET['DIFF']["/tmp/foo"],
|
|
+ "/tmp/foo2": MODULE_RET['DIFF']["/tmp/foo2"],
|
|
+ }
|
|
+ self.assertEqual(snapper.diff(), module_ret)
|
|
+
|
|
+ @patch('salt.modules.snapper._get_num_interval', MagicMock(return_value=(55, 0)))
|
|
+ @patch('salt.modules.snapper.snapper.MountSnapshot', MagicMock(
|
|
+ side_effect=["/.snapshots/55/snapshot", "", "/.snapshots/55/snapshot", ""]))
|
|
+ @patch('salt.modules.snapper.snapper.UmountSnapshot', MagicMock(return_value=""))
|
|
+ @patch('salt.modules.snapper.changed_files', MagicMock(return_value=["/tmp/foo3"]))
|
|
+ @patch('salt.modules.snapper._is_text_file', MagicMock(return_value=False))
|
|
+ @patch('os.path.isfile', MagicMock(side_effect=[True, True]))
|
|
+ @patch('os.path.isdir', MagicMock(return_value=False))
|
|
+ @patch.dict(snapper.__salt__, {
|
|
+ 'hashutil.sha256_digest': MagicMock(side_effect=[
|
|
+ "e61f8b762d83f3b4aeb3689564b0ffbe54fa731a69a1e208dc9440ce0f69d19b",
|
|
+ "f18f971f1517449208a66589085ddd3723f7f6cefb56c141e3d97ae49e1d87fa",
|
|
+ ])
|
|
+ })
|
|
+ def test_diff_binary_files(self):
|
|
+ fopen_effect = [
|
|
+ mock_open(read_data="dummy binary").return_value,
|
|
+ mock_open(read_data="dummy binary").return_value,
|
|
+ ]
|
|
+ with patch('salt.utils.fopen') as fopen_mock:
|
|
+ fopen_mock.side_effect = fopen_effect
|
|
+ module_ret = {
|
|
+ "/tmp/foo3": MODULE_RET['DIFF']["/tmp/foo3"],
|
|
+ }
|
|
+ self.assertEqual(snapper.diff(), module_ret)
|
|
+
|
|
+
|
|
+if __name__ == '__main__':
|
|
+ from integration import run_tests
|
|
+ run_tests(SnapperTestCase, needs_daemon=False)
|
|
--
|
|
2.9.2
|
|
|