EVOLUTION-MANAGER
Edit File: cron.py
#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright: (c) 2012, Dane Summers <dsummers@pinedesk.biz> # Copyright: (c) 2013, Mike Grozak <mike.grozak@gmail.com> # Copyright: (c) 2013, Patrick Callahan <pmc@patrickcallahan.com> # Copyright: (c) 2015, Evan Kaufman <evan@digitalflophouse.com> # Copyright: (c) 2015, Luca Berruti <nadirio@gmail.com> # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = r''' --- module: cron short_description: Manage cron.d and crontab entries description: - Use this module to manage crontab and environment variables entries. This module allows you to create environment variables and named crontab entries, update, or delete them. - 'When crontab jobs are managed: the module includes one line with the description of the crontab entry C("#Ansible: <name>") corresponding to the "name" passed to the module, which is used by future ansible/module calls to find/check the state. The "name" parameter should be unique, and changing the "name" value will result in a new cron task being created (or a different one being removed).' - When environment variables are managed, no comment line is added, but, when the module needs to find/check the state, it uses the "name" parameter to find the environment variable definition line. - When using symbols such as %, they must be properly escaped. version_added: "0.9" options: name: description: - Description of a crontab entry or, if env is set, the name of environment variable. - Required if I(state=absent). - Note that if name is not set and I(state=present), then a new crontab entry will always be created, regardless of existing ones. - This parameter will always be required in future releases. type: str user: description: - The specific user whose crontab should be modified. - When unset, this parameter defaults to the current user. type: str job: description: - The command to execute or, if env is set, the value of environment variable. - The command should not contain line breaks. - Required if I(state=present). type: str aliases: [ value ] state: description: - Whether to ensure the job or environment variable is present or absent. type: str choices: [ absent, present ] default: present cron_file: description: - If specified, uses this file instead of an individual user's crontab. - If this is a relative path, it is interpreted with respect to I(/etc/cron.d). - If it is absolute, it will typically be C(/etc/crontab). - Many linux distros expect (and some require) the filename portion to consist solely of upper- and lower-case letters, digits, underscores, and hyphens. - To use the I(cron_file) parameter you must specify the I(user) as well. type: str backup: description: - If set, create a backup of the crontab before it is modified. The location of the backup is returned in the C(backup_file) variable by this module. type: bool default: no minute: description: - Minute when the job should run (C(0-59), C(*), C(*/2), and so on). type: str default: "*" hour: description: - Hour when the job should run (C(0-23), C(*), C(*/2), and so on). type: str default: "*" day: description: - Day of the month the job should run (C(1-31), C(*), C(*/2), and so on). type: str default: "*" aliases: [ dom ] month: description: - Month of the year the job should run (C(1-12), C(*), C(*/2), and so on). type: str default: "*" weekday: description: - Day of the week that the job should run (C(0-6) for Sunday-Saturday, C(*), and so on). type: str default: "*" aliases: [ dow ] reboot: description: - If the job should be run at reboot. This option is deprecated. Users should use I(special_time). version_added: "1.0" type: bool default: no special_time: description: - Special time specification nickname. type: str choices: [ annually, daily, hourly, monthly, reboot, weekly, yearly ] version_added: "1.3" disabled: description: - If the job should be disabled (commented out) in the crontab. - Only has effect if I(state=present). type: bool default: no version_added: "2.0" env: description: - If set, manages a crontab's environment variable. - New variables are added on top of crontab. - I(name) and I(value) parameters are the name and the value of environment variable. type: bool default: false version_added: "2.1" insertafter: description: - Used with I(state=present) and I(env). - If specified, the environment variable will be inserted after the declaration of specified environment variable. type: str version_added: "2.1" insertbefore: description: - Used with I(state=present) and I(env). - If specified, the environment variable will be inserted before the declaration of specified environment variable. type: str version_added: "2.1" requirements: - cron (or cronie on CentOS) author: - Dane Summers (@dsummersl) - Mike Grozak (@rhaido) - Patrick Callahan (@dirtyharrycallahan) - Evan Kaufman (@EvanK) - Luca Berruti (@lberruti) notes: - Supports C(check_mode). ''' EXAMPLES = r''' - name: Ensure a job that runs at 2 and 5 exists. Creates an entry like "0 5,2 * * ls -alh > /dev/null" ansible.builtin.cron: name: "check dirs" minute: "0" hour: "5,2" job: "ls -alh > /dev/null" - name: 'Ensure an old job is no longer present. Removes any job that is prefixed by "#Ansible: an old job" from the crontab' ansible.builtin.cron: name: "an old job" state: absent - name: Creates an entry like "@reboot /some/job.sh" ansible.builtin.cron: name: "a job for reboot" special_time: reboot job: "/some/job.sh" - name: Creates an entry like "PATH=/opt/bin" on top of crontab ansible.builtin.cron: name: PATH env: yes job: /opt/bin - name: Creates an entry like "APP_HOME=/srv/app" and insert it after PATH declaration ansible.builtin.cron: name: APP_HOME env: yes job: /srv/app insertafter: PATH - name: Creates a cron file under /etc/cron.d ansible.builtin.cron: name: yum autoupdate weekday: "2" minute: "0" hour: "12" user: root job: "YUMINTERACTIVE=0 /usr/sbin/yum-autoupdate" cron_file: ansible_yum-autoupdate - name: Removes a cron file from under /etc/cron.d ansible.builtin.cron: name: "yum autoupdate" cron_file: ansible_yum-autoupdate state: absent - name: Removes "APP_HOME" environment variable from crontab ansible.builtin.cron: name: APP_HOME env: yes state: absent ''' RETURN = r'''#''' import os import platform import pwd import re import sys import tempfile from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_bytes, to_native from ansible.module_utils.six.moves import shlex_quote class CronTabError(Exception): pass class CronTab(object): """ CronTab object to write time based crontab file user - the user of the crontab (defaults to current user) cron_file - a cron file under /etc/cron.d, or an absolute path """ def __init__(self, module, user=None, cron_file=None): self.module = module self.user = user self.root = (os.getuid() == 0) self.lines = None self.ansible = "#Ansible: " self.n_existing = '' self.cron_cmd = self.module.get_bin_path('crontab', required=True) if cron_file: if os.path.isabs(cron_file): self.cron_file = cron_file self.b_cron_file = to_bytes(cron_file, errors='surrogate_or_strict') else: self.cron_file = os.path.join('/etc/cron.d', cron_file) self.b_cron_file = os.path.join(b'/etc/cron.d', to_bytes(cron_file, errors='surrogate_or_strict')) else: self.cron_file = None self.read() def read(self): # Read in the crontab from the system self.lines = [] if self.cron_file: # read the cronfile try: f = open(self.b_cron_file, 'rb') self.n_existing = to_native(f.read(), errors='surrogate_or_strict') self.lines = self.n_existing.splitlines() f.close() except IOError: # cron file does not exist return except Exception: raise CronTabError("Unexpected error:", sys.exc_info()[0]) else: # using safely quoted shell for now, but this really should be two non-shell calls instead. FIXME (rc, out, err) = self.module.run_command(self._read_user_execute(), use_unsafe_shell=True) if rc != 0 and rc != 1: # 1 can mean that there are no jobs. raise CronTabError("Unable to read crontab") self.n_existing = out lines = out.splitlines() count = 0 for l in lines: if count > 2 or (not re.match(r'# DO NOT EDIT THIS FILE - edit the master and reinstall.', l) and not re.match(r'# \(/tmp/.*installed on.*\)', l) and not re.match(r'# \(.*version.*\)', l)): self.lines.append(l) else: pattern = re.escape(l) + '[\r\n]?' self.n_existing = re.sub(pattern, '', self.n_existing, 1) count += 1 def is_empty(self): if len(self.lines) == 0: return True else: for line in self.lines: if line.strip(): return False return True def write(self, backup_file=None): """ Write the crontab to the system. Saves all information. """ if backup_file: fileh = open(backup_file, 'wb') elif self.cron_file: fileh = open(self.b_cron_file, 'wb') else: filed, path = tempfile.mkstemp(prefix='crontab') os.chmod(path, int('0644', 8)) fileh = os.fdopen(filed, 'wb') fileh.write(to_bytes(self.render())) fileh.close() # return if making a backup if backup_file: return # Add the entire crontab back to the user crontab if not self.cron_file: # quoting shell args for now but really this should be two non-shell calls. FIXME (rc, out, err) = self.module.run_command(self._write_execute(path), use_unsafe_shell=True) os.unlink(path) if rc != 0: self.module.fail_json(msg=err) # set SELinux permissions if self.module.selinux_enabled() and self.cron_file: self.module.set_default_selinux_context(self.cron_file, False) def do_comment(self, name): return "%s%s" % (self.ansible, name) def add_job(self, name, job): # Add the comment self.lines.append(self.do_comment(name)) # Add the job self.lines.append("%s" % (job)) def update_job(self, name, job): return self._update_job(name, job, self.do_add_job) def do_add_job(self, lines, comment, job): lines.append(comment) lines.append("%s" % (job)) def remove_job(self, name): return self._update_job(name, "", self.do_remove_job) def do_remove_job(self, lines, comment, job): return None def add_env(self, decl, insertafter=None, insertbefore=None): if not (insertafter or insertbefore): self.lines.insert(0, decl) return if insertafter: other_name = insertafter elif insertbefore: other_name = insertbefore other_decl = self.find_env(other_name) if len(other_decl) > 0: if insertafter: index = other_decl[0] + 1 elif insertbefore: index = other_decl[0] self.lines.insert(index, decl) return self.module.fail_json(msg="Variable named '%s' not found." % other_name) def update_env(self, name, decl): return self._update_env(name, decl, self.do_add_env) def do_add_env(self, lines, decl): lines.append(decl) def remove_env(self, name): return self._update_env(name, '', self.do_remove_env) def do_remove_env(self, lines, decl): return None def remove_job_file(self): try: os.unlink(self.cron_file) return True except OSError: # cron file does not exist return False except Exception: raise CronTabError("Unexpected error:", sys.exc_info()[0]) def find_job(self, name, job=None): # attempt to find job by 'Ansible:' header comment comment = None for l in self.lines: if comment is not None: if comment == name: return [comment, l] else: comment = None elif re.match(r'%s' % self.ansible, l): comment = re.sub(r'%s' % self.ansible, '', l) # failing that, attempt to find job by exact match if job: for i, l in enumerate(self.lines): if l == job: # if no leading ansible header, insert one if not re.match(r'%s' % self.ansible, self.lines[i - 1]): self.lines.insert(i, self.do_comment(name)) return [self.lines[i], l, True] # if a leading blank ansible header AND job has a name, update header elif name and self.lines[i - 1] == self.do_comment(None): self.lines[i - 1] = self.do_comment(name) return [self.lines[i - 1], l, True] return [] def find_env(self, name): for index, l in enumerate(self.lines): if re.match(r'^%s=' % name, l): return [index, l] return [] def get_cron_job(self, minute, hour, day, month, weekday, job, special, disabled): # normalize any leading/trailing newlines (ansible/ansible-modules-core#3791) job = job.strip('\r\n') if disabled: disable_prefix = '#' else: disable_prefix = '' if special: if self.cron_file: return "%s@%s %s %s" % (disable_prefix, special, self.user, job) else: return "%s@%s %s" % (disable_prefix, special, job) else: if self.cron_file: return "%s%s %s %s %s %s %s %s" % (disable_prefix, minute, hour, day, month, weekday, self.user, job) else: return "%s%s %s %s %s %s %s" % (disable_prefix, minute, hour, day, month, weekday, job) def get_jobnames(self): jobnames = [] for l in self.lines: if re.match(r'%s' % self.ansible, l): jobnames.append(re.sub(r'%s' % self.ansible, '', l)) return jobnames def get_envnames(self): envnames = [] for l in self.lines: if re.match(r'^\S+=', l): envnames.append(l.split('=')[0]) return envnames def _update_job(self, name, job, addlinesfunction): ansiblename = self.do_comment(name) newlines = [] comment = None for l in self.lines: if comment is not None: addlinesfunction(newlines, comment, job) comment = None elif l == ansiblename: comment = l else: newlines.append(l) self.lines = newlines if len(newlines) == 0: return True else: return False # TODO add some more error testing def _update_env(self, name, decl, addenvfunction): newlines = [] for l in self.lines: if re.match(r'^%s=' % name, l): addenvfunction(newlines, decl) else: newlines.append(l) self.lines = newlines def render(self): """ Render this crontab as it would be in the crontab. """ crons = [] for cron in self.lines: crons.append(cron) result = '\n'.join(crons) if result: result = result.rstrip('\r\n') + '\n' return result def _read_user_execute(self): """ Returns the command line for reading a crontab """ user = '' if self.user: if platform.system() == 'SunOS': return "su %s -c '%s -l'" % (shlex_quote(self.user), shlex_quote(self.cron_cmd)) elif platform.system() == 'AIX': return "%s -l %s" % (shlex_quote(self.cron_cmd), shlex_quote(self.user)) elif platform.system() == 'HP-UX': return "%s %s %s" % (self.cron_cmd, '-l', shlex_quote(self.user)) elif pwd.getpwuid(os.getuid())[0] != self.user: user = '-u %s' % shlex_quote(self.user) return "%s %s %s" % (self.cron_cmd, user, '-l') def _write_execute(self, path): """ Return the command line for writing a crontab """ user = '' if self.user: if platform.system() in ['SunOS', 'HP-UX', 'AIX']: return "chown %s %s ; su '%s' -c '%s %s'" % ( shlex_quote(self.user), shlex_quote(path), shlex_quote(self.user), self.cron_cmd, shlex_quote(path)) elif pwd.getpwuid(os.getuid())[0] != self.user: user = '-u %s' % shlex_quote(self.user) return "%s %s %s" % (self.cron_cmd, user, shlex_quote(path)) def main(): # The following example playbooks: # # - cron: name="check dirs" hour="5,2" job="ls -alh > /dev/null" # # - name: do the job # cron: name="do the job" hour="5,2" job="/some/dir/job.sh" # # - name: no job # cron: name="an old job" state=absent # # - name: sets env # cron: name="PATH" env=yes value="/bin:/usr/bin" # # Would produce: # PATH=/bin:/usr/bin # # Ansible: check dirs # * * 5,2 * * ls -alh > /dev/null # # Ansible: do the job # * * 5,2 * * /some/dir/job.sh module = AnsibleModule( argument_spec=dict( name=dict(type='str'), user=dict(type='str'), job=dict(type='str', aliases=['value']), cron_file=dict(type='str'), state=dict(type='str', default='present', choices=['present', 'absent']), backup=dict(type='bool', default=False), minute=dict(type='str', default='*'), hour=dict(type='str', default='*'), day=dict(type='str', default='*', aliases=['dom']), month=dict(type='str', default='*'), weekday=dict(type='str', default='*', aliases=['dow']), reboot=dict(type='bool', default=False), special_time=dict(type='str', choices=["reboot", "yearly", "annually", "monthly", "weekly", "daily", "hourly"]), disabled=dict(type='bool', default=False), env=dict(type='bool', default=False), insertafter=dict(type='str'), insertbefore=dict(type='str'), ), supports_check_mode=True, mutually_exclusive=[ ['reboot', 'special_time'], ['insertafter', 'insertbefore'], ], ) name = module.params['name'] user = module.params['user'] job = module.params['job'] cron_file = module.params['cron_file'] state = module.params['state'] backup = module.params['backup'] minute = module.params['minute'] hour = module.params['hour'] day = module.params['day'] month = module.params['month'] weekday = module.params['weekday'] reboot = module.params['reboot'] special_time = module.params['special_time'] disabled = module.params['disabled'] env = module.params['env'] insertafter = module.params['insertafter'] insertbefore = module.params['insertbefore'] do_install = state == 'present' changed = False res_args = dict() warnings = list() if cron_file: cron_file_basename = os.path.basename(cron_file) if not re.search(r'^[A-Z0-9_-]+$', cron_file_basename, re.I): warnings.append('Filename portion of cron_file ("%s") should consist' % cron_file_basename + ' solely of upper- and lower-case letters, digits, underscores, and hyphens') # Ensure all files generated are only writable by the owning user. Primarily relevant for the cron_file option. os.umask(int('022', 8)) crontab = CronTab(module, user, cron_file) module.debug('cron instantiated - name: "%s"' % name) if not name: module.deprecate( msg="The 'name' parameter will be required in future releases.", version='2.12', collection_name='ansible.builtin' ) if reboot: module.deprecate( msg="The 'reboot' parameter will be removed in future releases. Use 'special_time' option instead.", version='2.12', collection_name='ansible.builtin' ) if module._diff: diff = dict() diff['before'] = crontab.n_existing if crontab.cron_file: diff['before_header'] = crontab.cron_file else: if crontab.user: diff['before_header'] = 'crontab for user "%s"' % crontab.user else: diff['before_header'] = 'crontab' # --- user input validation --- if env and not name: module.fail_json(msg="You must specify 'name' while working with environment variables (env=yes)") if (special_time or reboot) and \ (True in [(x != '*') for x in [minute, hour, day, month, weekday]]): module.fail_json(msg="You must specify time and date fields or special time.") # cannot support special_time on solaris if (special_time or reboot) and platform.system() == 'SunOS': module.fail_json(msg="Solaris does not support special_time=... or @reboot") if cron_file and do_install: if not user: module.fail_json(msg="To use cron_file=... parameter you must specify user=... as well") if job is None and do_install: module.fail_json(msg="You must specify 'job' to install a new cron job or variable") if (insertafter or insertbefore) and not env and do_install: module.fail_json(msg="Insertafter and insertbefore parameters are valid only with env=yes") if reboot: special_time = "reboot" # if requested make a backup before making a change if backup and not module.check_mode: (backuph, backup_file) = tempfile.mkstemp(prefix='crontab') crontab.write(backup_file) if env: if ' ' in name: module.fail_json(msg="Invalid name for environment variable") decl = '%s="%s"' % (name, job) old_decl = crontab.find_env(name) if do_install: if len(old_decl) == 0: crontab.add_env(decl, insertafter, insertbefore) changed = True if len(old_decl) > 0 and old_decl[1] != decl: crontab.update_env(name, decl) changed = True else: if len(old_decl) > 0: crontab.remove_env(name) changed = True else: if do_install: for char in ['\r', '\n']: if char in job.strip('\r\n'): warnings.append('Job should not contain line breaks') break job = crontab.get_cron_job(minute, hour, day, month, weekday, job, special_time, disabled) old_job = crontab.find_job(name, job) if len(old_job) == 0: crontab.add_job(name, job) changed = True if len(old_job) > 0 and old_job[1] != job: crontab.update_job(name, job) changed = True if len(old_job) > 2: crontab.update_job(name, job) changed = True else: old_job = crontab.find_job(name) if len(old_job) > 0: crontab.remove_job(name) changed = True if crontab.cron_file and crontab.is_empty(): if module._diff: diff['after'] = '' diff['after_header'] = '/dev/null' else: diff = dict() if module.check_mode: changed = os.path.isfile(crontab.cron_file) else: changed = crontab.remove_job_file() module.exit_json(changed=changed, cron_file=cron_file, state=state, diff=diff) # no changes to env/job, but existing crontab needs a terminating newline if not changed and crontab.n_existing != '': if not (crontab.n_existing.endswith('\r') or crontab.n_existing.endswith('\n')): changed = True res_args = dict( jobs=crontab.get_jobnames(), envs=crontab.get_envnames(), warnings=warnings, changed=changed ) if changed: if not module.check_mode: crontab.write() if module._diff: diff['after'] = crontab.render() if crontab.cron_file: diff['after_header'] = crontab.cron_file else: if crontab.user: diff['after_header'] = 'crontab for user "%s"' % crontab.user else: diff['after_header'] = 'crontab' res_args['diff'] = diff # retain the backup only if crontab or cron file have changed if backup and not module.check_mode: if changed: res_args['backup_file'] = backup_file else: os.unlink(backup_file) if cron_file: res_args['cron_file'] = cron_file module.exit_json(**res_args) # --- should never get here module.exit_json(msg="Unable to execute cron task.") if __name__ == '__main__': main()