saltstack/salt

View on GitHub
salt/modules/cron.py

Summary

Maintainability
F
1 wk
Test Coverage
# -*- coding: utf-8 -*-
'''
Work with cron

.. note::
    Salt does not escape cron metacharacters automatically. You should
    backslash-escape percent characters and any other metacharacters that might
    be interpreted incorrectly by the shell.
'''
from __future__ import absolute_import, unicode_literals, print_function

# Import python libs
import os
import random
import logging

# Import salt libs
import salt.utils.data
import salt.utils.files
import salt.utils.functools
import salt.utils.path
import salt.utils.stringutils

from salt.ext import six
from salt.ext.six.moves import range

TAG = '# Lines below here are managed by Salt, do not edit\n'
SALT_CRON_IDENTIFIER = 'SALT_CRON_IDENTIFIER'
SALT_CRON_NO_IDENTIFIER = 'NO ID SET'

log = logging.getLogger(__name__)


def __virtual__():
    if salt.utils.path.which('crontab'):
        return True
    else:
        return (False, 'Cannot load cron module: crontab command not found')


def _ensure_string(val):
    # Account for cases where the identifier is not a string
    # which would cause to_unicode to fail.
    if not isinstance(val, six.string_types):
        val = str(val)  # future lint: enable=blacklisted-function
    try:
        return salt.utils.stringutils.to_unicode(val)
    except TypeError:
        return ''


def _cron_id(cron):
    '''SAFETYBELT, Only set if we really have an identifier'''
    cid = None
    if cron['identifier']:
        cid = cron['identifier']
    else:
        cid = SALT_CRON_NO_IDENTIFIER
    if cid:
        return _ensure_string(cid)


def _cron_matched(cron, cmd, identifier=None):
    '''Check if:
      - we find a cron with same cmd, old state behavior
      - but also be smart enough to remove states changed crons where we do
        not removed priorly by a cron.absent by matching on the provided
        identifier.
        We assure retrocompatibility by only checking on identifier if
        and only if an identifier was set on the serialized crontab
    '''
    ret, id_matched = False, None
    cid = _cron_id(cron)
    if cid:
        if not identifier:
            identifier = SALT_CRON_NO_IDENTIFIER
        eidentifier = _ensure_string(identifier)
        # old style second round
        # after saving crontab, we must check that if
        # we have not the same command, but the default id
        # to not set that as a match
        if (
            cron.get('cmd', None) != cmd
            and cid == SALT_CRON_NO_IDENTIFIER
            and eidentifier == SALT_CRON_NO_IDENTIFIER
        ):
            id_matched = False
        else:
            # on saving, be sure not to overwrite a cron
            # with specific identifier but also track
            # crons where command is the same
            # but with the default if that we gonna overwrite
            if (
                cron.get('cmd', None) == cmd
                and cid == SALT_CRON_NO_IDENTIFIER
                and identifier
            ):
                cid = eidentifier
            id_matched = eidentifier == cid
    if (
        ((id_matched is None) and cmd == cron.get('cmd', None))
        or id_matched
    ):
        ret = True
    return ret


def _needs_change(old, new):
    if old != new:
        if new == 'random':
            # Allow switch from '*' or not present to 'random'
            if old == '*':
                return True
        elif new is not None:
            return True
    return False


def _render_tab(lst):
    '''
    Takes a tab list structure and renders it to a list for applying it to
    a file
    '''
    ret = []
    for pre in lst['pre']:
        ret.append('{0}\n'.format(pre))
    if ret:
        if ret[-1] != TAG:
            ret.append(TAG)
    else:
        ret.append(TAG)
    for env in lst['env']:
        if (env['value'] is None) or (env['value'] == ""):
            ret.append('{0}=""\n'.format(env['name']))
        else:
            ret.append('{0}={1}\n'.format(env['name'], env['value']))
    for cron in lst['crons']:
        if cron['comment'] is not None or cron['identifier'] is not None:
            comment = '#'
            if cron['comment']:
                comment += ' {0}'.format(
                    cron['comment'].replace('\n', '\n# '))
            if cron['identifier']:
                comment += ' {0}:{1}'.format(SALT_CRON_IDENTIFIER,
                                             cron['identifier'])

            comment += '\n'
            ret.append(comment)
        ret.append('{0}{1} {2} {3} {4} {5} {6}\n'.format(
                            cron['commented'] is True and '#DISABLED#' or '',
                            cron['minute'],
                            cron['hour'],
                            cron['daymonth'],
                            cron['month'],
                            cron['dayweek'],
                            cron['cmd']
                            )
                   )
    for cron in lst['special']:
        if cron['comment'] is not None or cron['identifier'] is not None:
            comment = '#'
            if cron['comment']:
                comment += ' {0}'.format(
                    cron['comment'].rstrip().replace('\n', '\n# '))
            if cron['identifier']:
                comment += ' {0}:{1}'.format(SALT_CRON_IDENTIFIER,
                                             cron['identifier'])

            comment += '\n'
            ret.append(comment)
        ret.append('{0}{1} {2}\n'.format(
                            cron['commented'] is True and '#DISABLED#' or '',
                            cron['spec'],
                            cron['cmd']
                            )
                  )
    return ret


def _get_cron_cmdstr(path, user=None):
    '''
    Returns a format string, to be used to build a crontab command.
    '''
    if user:
        cmd = 'crontab -u {0}'.format(user)
    else:
        cmd = 'crontab'
    return '{0} {1}'.format(cmd, path)


def _check_instance_uid_match(user):
    '''
    Returns true if running instance's UID matches the specified user UID
    '''
    return os.geteuid() == __salt__['file.user_to_uid'](user)


def write_cron_file(user, path):
    '''
    Writes the contents of a file to a user's crontab

    CLI Example:

    .. code-block:: bash

        salt '*' cron.write_cron_file root /tmp/new_cron

    .. versionchanged:: 2015.8.9

    .. note::

        Some OS' do not support specifying user via the `crontab` command i.e. (Solaris, AIX)
    '''
    # Some OS' do not support specifying user via the `crontab` command
    if __grains__.get('os_family') in ('Solaris', 'AIX'):
        return __salt__['cmd.retcode'](_get_cron_cmdstr(path),
                                       runas=user,
                                       python_shell=False) == 0
    # If Salt is running from same user as requested in cron module we don't need any user switch
    elif _check_instance_uid_match(user):
        return __salt__['cmd.retcode'](_get_cron_cmdstr(path),
                                       python_shell=False) == 0
    # If Salt is running from root user it could modify any user's crontab
    elif _check_instance_uid_match('root'):
        return __salt__['cmd.retcode'](_get_cron_cmdstr(path, user),
                                       python_shell=False) == 0
    # Edge cases here, let's try do a runas
    else:
        return __salt__['cmd.retcode'](_get_cron_cmdstr(path),
                                       runas=user,
                                       python_shell=False) == 0


def write_cron_file_verbose(user, path):
    '''
    Writes the contents of a file to a user's crontab and return error message on error

    CLI Example:

    .. code-block:: bash

        salt '*' cron.write_cron_file_verbose root /tmp/new_cron

    .. versionchanged:: 2015.8.9

    .. note::

        Some OS' do not support specifying user via the `crontab` command i.e. (Solaris, AIX)
    '''
    # Some OS' do not support specifying user via the `crontab` command
    if __grains__.get('os_family') in ('Solaris', 'AIX'):
        return __salt__['cmd.run_all'](_get_cron_cmdstr(path),
                                       runas=user,
                                       python_shell=False)
    # If Salt is running from same user as requested in cron module we don't need any user switch
    elif _check_instance_uid_match(user):
        return __salt__['cmd.run_all'](_get_cron_cmdstr(path),
                                       python_shell=False)
    # If Salt is running from root user it could modify any user's crontab
    elif _check_instance_uid_match('root'):
        return __salt__['cmd.run_all'](_get_cron_cmdstr(path, user),
                                       python_shell=False)
    # Edge cases here, let's try do a runas
    else:
        return __salt__['cmd.run_all'](_get_cron_cmdstr(path),
                                       runas=user,
                                       python_shell=False)


def _write_cron_lines(user, lines):
    '''
    Takes a list of lines to be committed to a user's crontab and writes it
    '''
    lines = [salt.utils.stringutils.to_str(_l) for _l in lines]
    path = salt.utils.files.mkstemp()
    if _check_instance_uid_match('root') or __grains__.get('os_family') in ('Solaris', 'AIX'):
        # In some cases crontab command should be executed as user rather than root
        with salt.utils.files.fpopen(path, 'w+', uid=__salt__['file.user_to_uid'](user), mode=0o600) as fp_:
            fp_.writelines(lines)
        ret = __salt__['cmd.run_all'](_get_cron_cmdstr(path),
                                      runas=user,
                                      python_shell=False)
    else:
        with salt.utils.files.fpopen(path, 'w+', mode=0o600) as fp_:
            fp_.writelines(lines)
        ret = __salt__['cmd.run_all'](_get_cron_cmdstr(path, user),
                                      python_shell=False)
    os.remove(path)
    return ret


def _date_time_match(cron, **kwargs):
    '''
    Returns true if the minute, hour, etc. params match their counterparts from
    the dict returned from list_tab().
    '''
    return all([kwargs.get(x) is None or cron[x] == six.text_type(kwargs[x])
                or (six.text_type(kwargs[x]).lower() == 'random' and cron[x] != '*')
                for x in ('minute', 'hour', 'daymonth', 'month', 'dayweek')])


def raw_cron(user):
    '''
    Return the contents of the user's crontab

    CLI Example:

    .. code-block:: bash

        salt '*' cron.raw_cron root
    '''
    # Some OS' do not support specifying user via the `crontab` command
    if __grains__.get('os_family') in ('Solaris', 'AIX'):
        cmd = 'crontab -l'
        # Preserve line endings
        lines = salt.utils.data.decode(
            __salt__['cmd.run_stdout'](cmd,
                                       runas=user,
                                       ignore_retcode=True,
                                       rstrip=False,
                                       python_shell=False)).splitlines(True)
    # If Salt is running from same user as requested in cron module we don't need any user switch
    elif _check_instance_uid_match(user):
        cmd = 'crontab -l'
        # Preserve line endings
        lines = salt.utils.data.decode(
            __salt__['cmd.run_stdout'](cmd,
                                       ignore_retcode=True,
                                       rstrip=False,
                                       python_shell=False)).splitlines(True)
    # If Salt is running from root user it could modify any user's crontab
    elif _check_instance_uid_match('root'):
        cmd = 'crontab -u {0} -l'.format(user)
        # Preserve line endings
        lines = salt.utils.data.decode(
            __salt__['cmd.run_stdout'](cmd,
                                       ignore_retcode=True,
                                       rstrip=False,
                                       python_shell=False)).splitlines(True)
    # Edge cases here, let's try do a runas
    else:
        cmd = 'crontab -l'
        # Preserve line endings
        lines = salt.utils.data.decode(
            __salt__['cmd.run_stdout'](cmd,
                                       runas=user,
                                       ignore_retcode=True,
                                       rstrip=False,
                                       python_shell=False)).splitlines(True)

    if lines and lines[0].startswith('# DO NOT EDIT THIS FILE - edit the master and reinstall.'):
        del lines[0:3]
    return ''.join(lines)


def list_tab(user):
    '''
    Return the contents of the specified user's crontab

    CLI Example:

    .. code-block:: bash

        salt '*' cron.list_tab root
    '''
    data = raw_cron(user)
    ret = {'pre': [],
           'crons': [],
           'special': [],
           'env': []}
    flag = False
    comment = None
    identifier = None
    for line in data.splitlines():
        if line == '# Lines below here are managed by Salt, do not edit':
            flag = True
            continue
        if flag:
            commented_cron_job = False
            if line.startswith('#DISABLED#'):
                # It's a commented cron job
                line = line[10:]
                commented_cron_job = True
            if line.startswith('@'):
                # Its a "special" line
                dat = {}
                comps = line.split()
                if len(comps) < 2:
                    # Invalid line
                    continue
                dat['spec'] = comps[0]
                dat['cmd'] = ' '.join(comps[1:])
                dat['identifier'] = identifier
                dat['comment'] = comment
                dat['commented'] = False
                if commented_cron_job:
                    dat['commented'] = True
                ret['special'].append(dat)
                identifier = None
                comment = None
                commented_cron_job = False
            elif line.startswith('#'):
                # It's a comment! Catch it!
                comment_line = line.lstrip('# ')

                # load the identifier if any
                if SALT_CRON_IDENTIFIER in comment_line:
                    parts = comment_line.split(SALT_CRON_IDENTIFIER)
                    comment_line = parts[0].rstrip()
                    # skip leading :
                    if len(parts[1]) > 1:
                        identifier = parts[1][1:]

                if comment is None:
                    comment = comment_line
                else:
                    comment += '\n' + comment_line
            elif line.find('=') > 0 and (' ' not in line or line.index('=') < line.index(' ')):
                # Appears to be a ENV setup line
                comps = line.split('=', 1)
                dat = {}
                dat['name'] = comps[0]
                dat['value'] = comps[1]
                ret['env'].append(dat)
            elif len(line.split(' ')) > 5:
                # Appears to be a standard cron line
                comps = line.split(' ')
                dat = {'minute': comps[0],
                       'hour': comps[1],
                       'daymonth': comps[2],
                       'month': comps[3],
                       'dayweek': comps[4],
                       'identifier': identifier,
                       'cmd': ' '.join(comps[5:]),
                       'comment': comment,
                       'commented': False}
                if commented_cron_job:
                    dat['commented'] = True
                ret['crons'].append(dat)
                identifier = None
                comment = None
                commented_cron_job = False
        else:
            ret['pre'].append(line)
    return ret


# For consistency's sake
ls = salt.utils.functools.alias_function(list_tab, 'ls')


def get_entry(user, identifier=None, cmd=None):
    '''
    Return the specified entry from user's crontab.
    identifier will be used if specified, otherwise will lookup cmd
    Either identifier or cmd should be specified.

    user:
        User's crontab to query

    identifier:
        Search for line with identifier

    cmd:
        Search for cron line with cmd

    CLI Example:

    .. code-block:: bash

        salt '*' cron.identifier_exists root identifier=task1
    '''
    cron_entries = list_tab(user).get('crons', False)
    for cron_entry in cron_entries:
        if identifier and cron_entry.get('identifier') == identifier:
            return cron_entry
        elif cmd and cron_entry.get('cmd') == cmd:
            return cron_entry
    return False


def set_special(user,
                special,
                cmd,
                commented=False,
                comment=None,
                identifier=None):
    '''
    Set up a special command in the crontab.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.set_special root @hourly 'echo foobar'
    '''
    lst = list_tab(user)
    for cron in lst['special']:
        cid = _cron_id(cron)
        if _cron_matched(cron, cmd, identifier):
            test_setted_id = (
                cron['identifier'] is None
                and SALT_CRON_NO_IDENTIFIER
                or cron['identifier'])
            tests = [(cron['comment'], comment),
                     (cron['commented'], commented),
                     (identifier, test_setted_id),
                     (cron['spec'], special)]
            if cid or identifier:
                tests.append((cron['cmd'], cmd))
            if any([_needs_change(x, y) for x, y in tests]):
                rm_special(user, cmd, identifier=cid)

                # Use old values when setting the new job if there was no
                # change needed for a given parameter
                if not _needs_change(cron['spec'], special):
                    special = cron['spec']
                if not _needs_change(cron['commented'], commented):
                    commented = cron['commented']
                if not _needs_change(cron['comment'], comment):
                    comment = cron['comment']
                if not _needs_change(cron['cmd'], cmd):
                    cmd = cron['cmd']
                    if (
                        cid == SALT_CRON_NO_IDENTIFIER
                    ):
                        if identifier:
                            cid = identifier
                        if (
                            cid == SALT_CRON_NO_IDENTIFIER
                            and cron['identifier'] is None
                        ):
                            cid = None
                        cron['identifier'] = cid
                if not cid or (
                    cid and not _needs_change(cid, identifier)
                ):
                    identifier = cid
                jret = set_special(user, special, cmd, commented=commented,
                                   comment=comment, identifier=identifier)
                if jret == 'new':
                    return 'updated'
                else:
                    return jret
            return 'present'
    cron = {'spec': special,
            'cmd': cmd,
            'identifier': identifier,
            'comment': comment,
            'commented': commented}
    lst['special'].append(cron)

    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat['retcode']:
        # Failed to commit, return the error
        return comdat['stderr']
    return 'new'


def _get_cron_date_time(**kwargs):
    '''
    Returns a dict of date/time values to be used in a cron entry
    '''
    # Define ranges (except daymonth, as it depends on the month)
    range_max = {
        'minute': list(list(range(60))),
        'hour': list(list(range(24))),
        'month': list(list(range(1, 13))),
        'dayweek': list(list(range(7)))
    }

    ret = {}
    for param in ('minute', 'hour', 'month', 'dayweek'):
        value = six.text_type(kwargs.get(param, '1')).lower()
        if value == 'random':
            ret[param] = six.text_type(random.sample(range_max[param], 1)[0])
        elif len(value.split(':')) == 2:
            cron_range = sorted(value.split(':'))
            start, end = int(cron_range[0]), int(cron_range[1])
            ret[param] = six.text_type(random.randint(start, end))
        else:
            ret[param] = value

    if ret['month'] in '1 3 5 7 8 10 12'.split():
        daymonth_max = 31
    elif ret['month'] in '4 6 9 11'.split():
        daymonth_max = 30
    else:
        # This catches both '2' and '*'
        daymonth_max = 28

    daymonth = six.text_type(kwargs.get('daymonth', '1')).lower()
    if daymonth == 'random':
        ret['daymonth'] = \
            six.text_type(random.sample(list(list(range(1, (daymonth_max + 1)))), 1)[0])
    else:
        ret['daymonth'] = daymonth

    return ret


def set_job(user,
            minute,
            hour,
            daymonth,
            month,
            dayweek,
            cmd,
            commented=False,
            comment=None,
            identifier=None):
    '''
    Sets a cron job up for a specified user.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.set_job root '*' '*' '*' '*' 1 /usr/local/weekly
    '''
    # Scrub the types
    minute = six.text_type(minute).lower()
    hour = six.text_type(hour).lower()
    daymonth = six.text_type(daymonth).lower()
    month = six.text_type(month).lower()
    dayweek = six.text_type(dayweek).lower()
    lst = list_tab(user)
    for cron in lst['crons']:
        cid = _cron_id(cron)
        if _cron_matched(cron, cmd, identifier):
            test_setted_id = (
                cron['identifier'] is None
                and SALT_CRON_NO_IDENTIFIER
                or cron['identifier'])
            tests = [(cron['comment'], comment),
                     (cron['commented'], commented),
                     (identifier, test_setted_id),
                     (cron['minute'], minute),
                     (cron['hour'], hour),
                     (cron['daymonth'], daymonth),
                     (cron['month'], month),
                     (cron['dayweek'], dayweek)]
            if cid or identifier:
                tests.append((cron['cmd'], cmd))
            if any([_needs_change(x, y) for x, y in tests]):
                rm_job(user, cmd, identifier=cid)

                # Use old values when setting the new job if there was no
                # change needed for a given parameter
                if not _needs_change(cron['minute'], minute):
                    minute = cron['minute']
                if not _needs_change(cron['hour'], hour):
                    hour = cron['hour']
                if not _needs_change(cron['daymonth'], daymonth):
                    daymonth = cron['daymonth']
                if not _needs_change(cron['month'], month):
                    month = cron['month']
                if not _needs_change(cron['dayweek'], dayweek):
                    dayweek = cron['dayweek']
                if not _needs_change(cron['commented'], commented):
                    commented = cron['commented']
                if not _needs_change(cron['comment'], comment):
                    comment = cron['comment']
                if not _needs_change(cron['cmd'], cmd):
                    cmd = cron['cmd']
                    if (
                        cid == SALT_CRON_NO_IDENTIFIER
                    ):
                        if identifier:
                            cid = identifier
                        if (
                            cid == SALT_CRON_NO_IDENTIFIER
                            and cron['identifier'] is None
                        ):
                            cid = None
                        cron['identifier'] = cid
                if not cid or (
                    cid and not _needs_change(cid, identifier)
                ):
                    identifier = cid
                jret = set_job(user, minute, hour, daymonth,
                               month, dayweek, cmd, commented=commented,
                               comment=comment, identifier=identifier)
                if jret == 'new':
                    return 'updated'
                else:
                    return jret
            return 'present'
    cron = {'cmd': cmd,
            'identifier': identifier,
            'comment': comment,
            'commented': commented}
    cron.update(_get_cron_date_time(minute=minute, hour=hour,
                                    daymonth=daymonth, month=month,
                                    dayweek=dayweek))
    lst['crons'].append(cron)

    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat['retcode']:
        # Failed to commit, return the error
        return comdat['stderr']
    return 'new'


def rm_special(user, cmd, special=None, identifier=None):
    '''
    Remove a special cron job for a specified user.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.rm_special root /usr/bin/foo
    '''
    lst = list_tab(user)
    ret = 'absent'
    rm_ = None
    for ind in range(len(lst['special'])):
        if rm_ is not None:
            break
        if _cron_matched(lst['special'][ind], cmd, identifier=identifier):
            if special is None:
                # No special param was specified
                rm_ = ind
            else:
                if lst['special'][ind]['spec'] == special:
                    rm_ = ind
    if rm_ is not None:
        lst['special'].pop(rm_)
        ret = 'removed'
    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat['retcode']:
        # Failed to commit, return the error
        return comdat['stderr']
    return ret


def rm_job(user,
           cmd,
           minute=None,
           hour=None,
           daymonth=None,
           month=None,
           dayweek=None,
           identifier=None):
    '''
    Remove a cron job for a specified user. If any of the day/time params are
    specified, the job will only be removed if the specified params match.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.rm_job root /usr/local/weekly
        salt '*' cron.rm_job root /usr/bin/foo dayweek=1
    '''
    lst = list_tab(user)
    ret = 'absent'
    rm_ = None
    for ind in range(len(lst['crons'])):
        if rm_ is not None:
            break
        if _cron_matched(lst['crons'][ind], cmd, identifier=identifier):
            if not any([x is not None
                        for x in (minute, hour, daymonth, month, dayweek)]):
                # No date/time params were specified
                rm_ = ind
            else:
                if _date_time_match(lst['crons'][ind],
                                    minute=minute,
                                    hour=hour,
                                    daymonth=daymonth,
                                    month=month,
                                    dayweek=dayweek):
                    rm_ = ind
    if rm_ is not None:
        lst['crons'].pop(rm_)
        ret = 'removed'
        comdat = _write_cron_lines(user, _render_tab(lst))
        if comdat['retcode']:
            # Failed to commit, return the error
            return comdat['stderr']
    return ret


rm = salt.utils.functools.alias_function(rm_job, 'rm')


def set_env(user, name, value=None):
    '''
    Set up an environment variable in the crontab.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.set_env root MAILTO user@example.com
    '''
    lst = list_tab(user)
    for env in lst['env']:
        if name == env['name']:
            if value != env['value']:
                rm_env(user, name)
                jret = set_env(user, name, value)
                if jret == 'new':
                    return 'updated'
                else:
                    return jret
            return 'present'
    env = {'name': name, 'value': value}
    lst['env'].append(env)
    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat['retcode']:
        # Failed to commit, return the error
        return comdat['stderr']
    return 'new'


def rm_env(user, name):
    '''
    Remove cron environment variable for a specified user.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.rm_env root MAILTO
    '''
    lst = list_tab(user)
    ret = 'absent'
    rm_ = None
    for ind in range(len(lst['env'])):
        if name == lst['env'][ind]['name']:
            rm_ = ind
    if rm_ is not None:
        lst['env'].pop(rm_)
        ret = 'removed'
    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat['retcode']:
        # Failed to commit, return the error
        return comdat['stderr']
    return ret