From 0bfb1e10a59dd13495ecb52620f2c5c05416acb6 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 61/61] 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.8.2

