diff --git a/system/cron b/system/cron index 633bccba8ad..fee6a2a1bfc 100644 --- a/system/cron +++ b/system/cron @@ -3,6 +3,7 @@ # # (c) 2012, Dane Summers # (c) 2013, Mike Grozak +# (c) 2013, Patrick Callahan # # This file is part of Ansible # @@ -18,17 +19,20 @@ # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . - +# # Cron Plugin: The goal of this plugin is to provide an indempotent method for # setting up cron jobs on a host. The script will play well with other manually # entered crons. Each cron job entered will be preceded with a comment # describing the job so that it can be found later, which is required to be # present in order for this plugin to find/modify the job. +# +# This module is based on python-crontab by Martin Owens. +# DOCUMENTATION = """ --- module: cron -short_description: Manage crontab entries. +short_description: Manage cron.d and crontab entries. description: - Use this module to manage crontab entries. This module allows you to create named crontab entries, update, or delete them. @@ -40,80 +44,76 @@ options: name: description: - Description of a crontab entry. - required: true - default: - aliases: [] + required: false + default: null user: description: - The specific user who's crontab should be modified. required: false default: root - aliases: [] job: description: - - The command to execute. - - Required if state=present. + - The command to execute. Required if state=present. required: false - default: - aliases: [] + default: null state: description: - - Whether to ensure the job is present or absent. + - Whether to ensure the job is present or absent. required: false default: present - aliases: [] + choices: [ "present", "absent" ] cron_file: description: - - If specified, uses this file in cron.d versus in the main crontab + - If specified, uses this file in cron.d instead of an individual user's crontab. required: false - default: - aliases: [] + default: null backup: description: - - If set, then create a backup of the crontab before it is modified. - - The location of the backup is returned in the C(backup) variable by this module. + - If set, create a backup of the crontab before it is modified. + The location of the backup is returned in the C(backup) variable by this module. required: false default: false - aliases: [] minute: description: - Minute when the job should run ( 0-59, *, */2, etc ) required: false default: "*" - aliases: [] hour: description: - Hour when the job should run ( 0-23, *, */2, etc ) required: false default: "*" - aliases: [] day: description: - Day of the month the job should run ( 1-31, *, */2, etc ) required: false default: "*" - aliases: [] + aliases: [ "dom" ] month: description: - Month of the year the job should run ( 1-12, *, */2, etc ) required: false default: "*" - aliases: [] weekday: description: - - Day of the week that the job should run ( 0-7 for Sunday - Saturday, or mon, tue, * etc ) + - Day of the week that the job should run ( 0-7 for Sunday - Saturday, *, etc ) required: false default: "*" - aliases: [] - + aliases: [ "dow" ] reboot: description: - - If the job should be run at reboot, will ignore minute, hour, day, and month settings in favour of C(@reboot) + - If the job should be run at reboot. This option is deprecated. Users should use special_time. version_added: "1.0" required: false default: "no" choices: [ "yes", "no" ] - aliases: [] + special_time: + description: + - Special time specification nickname. + version_added: "1.3" + required: false + default: null + choices: [ "reboot", "yearly", "annually", "monthly", "weekly", "daily", "hourly" ] requirements: - cron author: Dane Summers @@ -127,124 +127,258 @@ EXAMPLES = ''' # Ensure an old job is no longer present. Removes any job that is prefixed # by "#Ansible: an old job" from the crontab -- cron: name="an old job" cron job="/some/dir/job.sh" state=absent +- cron: name="an old job" state=absent # Creates an entry like "@reboot /some/job.sh" -- cron: name="a job for reboot" reboot=yes job="/some/job.sh" +- cron: name="a job for reboot" special_time=reboot job="/some/job.sh" +# Creates a cron file under /etc/cron.d - cron: name="yum autoupdate" weekday="2" minute=0 hour=12 user="root" job="YUMINTERACTIVE=0 /usr/sbin/yum-autoupdate" cron_file=ansible_yum-autoupdate + +# Removes a cron file from under /etc/cron.d +- cron: cron_file=ansible_yum-autoupdate state=absent ''' +import os import re import tempfile -import os -def get_jobs_file(module, user, tmpfile, cron_file): - if cron_file: - cmd = "cp -fp /etc/cron.d/%s %s" % (cron_file, tmpfile) - else: - cmd = "crontab -l %s > %s" % (user,tmpfile) - - return module.run_command(cmd) +CRONCMD = "/usr/bin/crontab" -def install_jobs(module, user, tmpfile, cron_file): - if cron_file: - cron_file = '/etc/cron.d/%s' % cron_file - module.atomic_move(tmpfile, cron_file) - else: - cmd = "crontab %s %s" % (user, tmpfile) - return module.run_command(cmd) - -def get_jobs(tmpfile): - lines = open(tmpfile).read().splitlines() - comment = None - jobs = [] - for l in lines: - if comment is not None: - jobs.append([comment,l]) - comment = None - elif re.match( r'#Ansible: ',l): - comment = re.sub( r'#Ansible: ', '', l) - return jobs - -def find_job(name,tmpfile): - jobs = get_jobs(tmpfile) - for j in jobs: - if j[0] == name: - return j - return [] - -def add_job(module,name,job,tmpfile): - f = open(tmpfile, 'a') - f.write("#Ansible: %s\n%s\n" % (name, job)) - f.close() - -def update_job(name,job,tmpfile): - return _update_job(name,job,tmpfile,do_add_job) - -def do_add_job(lines, comment, job): - lines.append(comment) - lines.append(job) - -def remove_job(name,tmpfile): - return _update_job(name, "", tmpfile, do_remove_job) - -def do_remove_job(lines,comment,job): - return None - -def remove_job_file(cron_file): - fname = "/etc/cron.d/%s" % (cron_file) - os.unlink(fname) - -def _update_job(name,job,tmpfile,addlinesfunction): - ansiblename = "#Ansible: %s" % (name) - f = open(tmpfile) - lines = f.read().splitlines() - f.close() - newlines = [] - comment = None - for l in lines: - if comment is not None: - addlinesfunction(newlines,comment,job) - comment = None - elif l == ansiblename: - comment = l - else: - newlines.append(l) - f = open(tmpfile, 'w') - for l in newlines: - f.write(l) - f.write('\n') - f.close() - - if len(newlines) == 0: - return True - else: - return False # TODO add some more error testing +class CronTabError(Exception): + pass + +class CronTab(object): + """ + CronTab object to write time based crontab file + + user - the user of the crontab (defaults to root) + cron_file - a cron file under /etc/cron.d + """ + 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: " + + # select whether we dump additional debug info through syslog + self.syslogging = False -def get_cron_job(minute,hour,day,month,weekday,job,user,cron_file,reboot): - if reboot: if cron_file: - return "@reboot %s %s" % (user, job) + self.cron_file = '/etc/cron.d/%s' % cron_file else: - return "@reboot %s" % (job) - else: - if cron_file: - return "%s %s %s %s %s %s %s" % (minute,hour,day,month,weekday,user,job) + 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.cron_file, 'r') + self.lines = f.read().splitlines() + f.close() + except IOError as e: + # cron file does not exist + return + except: + raise CronTabError("Unexpected error:", sys.exc_info()[0]) + else: + (rc, out, err) = self.module.run_command(self._read_user_execute()) + + if rc != 0 and rc != 1: # 1 can mean that there are no jobs. + raise CronTabError("Unable to read crontab") + + 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) + count += 1 + + def log_message(self, message): + if self.syslogging: + syslog.syslog(syslog.LOG_NOTICE, 'ansible: "%s"' % message) + + def is_empty(self): + if len(self.lines) == 0: + return True + else: + return False + + def write(self, backup_file=None): + """ + Write the crontab to the system. Saves all information. + """ + if backup_file: + fileh = open(backup_file, 'w') + elif self.cron_file: + fileh = open(self.cron_file, 'w') + else: + filed, path = tempfile.mkstemp(prefix='crontab') + fileh = os.fdopen(filed, 'w') + + fileh.write(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: + # os.system(self._write_execute(path)) + (rc, out, err) = self.module.run_command(self._write_execute(path)) + os.unlink(path) + + if rc != 0: + self.module.fail_json(msg=err) + + def add_job(self, name, job): + # Add the comment + self.lines.append("%s%s" % (self.ansible, 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 remove_job_file(self): + try: + os.unlink(self.cron_file) + except OSError as e: + # cron file does not exist + return + except: + raise CronTabError("Unexpected error:", sys.exc_info()[0]) + + def find_job(self, name): + 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) + + return [] + + def get_cron_job(self,minute,hour,day,month,weekday,job,special): + if special: + if self.cron_file: + return "@%s %s %s" % (special, self.user, job) + else: + return "@%s %s" % (special, job) else: - return "%s %s %s %s %s %s" % (minute,hour,day,month,weekday,job) + if self.cron_file: + return "%s %s %s %s %s %s %s" % (minute,hour,day,month,weekday,self.user,job) + else: + return "%s %s %s %s %s %s" % (minute,hour,day,month,weekday,job) + + return None + + 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 None + return jobnames + + def _update_job(self, name, job, addlinesfunction): + ansiblename = "%s%s" % (self.ansible, 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 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 and result[-1] not in ['\n', '\r']: + result += '\n' + return result + + def _read_file_execute(self): + """ + Returns the command line for reading a crontab + """ + return "%s -l%s" % (CRONCMD, self._user_execute()) + + def _read_user_execute(self): + """ + Returns the command line for reading a crontab + """ + return "%s -l %s" % (CRONCMD, self._user_execute()) + + def _write_execute(self, path): + """ + Return the command line for writing a crontab + """ + return "%s %s %s" % (CRONCMD, path, self._user_execute()) + + def _user_execute(self): + """ + User command switches to append to the read and write commands. + """ + if self.user: + return "%s %s" % (' -u ', str(self.user)) + return '' + +#================================================== def main(): # The following example playbooks: - # - action: cron name="check dirs" hour="5,2" job="ls -alh > /dev/null" + # + # - cron: name="check dirs" hour="5,2" job="ls -alh > /dev/null" + # # - name: do the job - # action: name="do the job" cron hour="5,2" job="/some/dir/job.sh" + # cron: name="do the job" hour="5,2" job="/some/dir/job.sh" + # # - name: no job - # action: name="an old job" cron job="/some/dir/job.sh" state=absent + # cron: name="an old job" state=absent # # Would produce: # # Ansible: check dirs @@ -252,19 +386,9 @@ def main(): # # Ansible: do the job # * * 5,2 * * /some/dir/job.sh - # Function: - # 1. dump the existing cron: - # crontab -l -u > /tmp/tmpfile - # 2. search for comment "^# Ansible: " followed by a cron. - # 3. if absent: remove if present (and say modified), otherwise return with no mod. - # 4. if present: if the same return no mod, if not present add (and say mod), if different add (and say mod) - # 5. Install new cron (if mod): - # crontab -u /tmp/tmpfile - # 6. return mod - module = AnsibleModule( argument_spec = dict( - name=dict(required=True), + name=dict(required=False), user=dict(required=False), job=dict(required=False), cron_file=dict(required=False), @@ -272,99 +396,113 @@ def main(): backup=dict(default=False, type='bool'), minute=dict(default='*'), hour=dict(default='*'), - day=dict(default='*'), + day=dict(aliases=['dom'], default='*'), month=dict(default='*'), - weekday=dict(default='*'), - reboot=dict(required=False, default=False, type='bool') - ) + weekday=dict(aliases=['dow'], default='*'), + reboot=dict(required=False, default=False, type='bool'), + special_time=dict(required=False, + default=None, + choices=["reboot", "yearly", "annually", "monthly", "weekly", "daily", "hourly"], + type='str') + ), + supports_check_mode = False, ) - backup = module.params['backup'] - name = module.params['name'] - user = module.params['user'] - job = module.params['job'] - cron_file = module.params['cron_file'] - minute = module.params['minute'] - hour = module.params['hour'] - day = module.params['day'] - month = module.params['month'] - weekday = module.params['weekday'] - reboot = module.params['reboot'] - state = module.params['state'] - do_install = module.params['state'] == 'present' - changed = False - - if reboot and (True in [(x != '*') for x in [minute, hour, day, month, weekday]]): - module.fail_json(msg="You must specify either reboot=True or any of minute, hour, day, month, weekday") - - if cron_file: + 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'] + do_install = state == 'present' + + changed = False + res_args = dict() + + crontab = CronTab(module, user, cron_file) + + if crontab.syslogging: + syslog.openlog('ansible-%s' % os.path.basename(__file__)) + syslog.syslog(syslog.LOG_NOTICE, 'cron instantiated - name: "%s"' % name) + + # --- user input validation --- + + 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.") + + if cron_file and do_install: if not user: module.fail_json(msg="To use file=... parameter you must specify user=... as well") - else: - if not user: - user = "" - else: - user = "-u %s" % (user) - job = get_cron_job(minute,hour,day,month,weekday,job,user,cron_file,reboot) - rc, out, err, rm, status = (0, None, None, None, None) + if reboot and special_time: + module.fail_json(msg="reboot and special_time are mutually exclusive") + + if name is None and do_install: + module.fail_json(msg="You must specify 'name' to install a new cron job") + if job is None and do_install: module.fail_json(msg="You must specify 'job' to install a new cron job") - tmpfile = tempfile.NamedTemporaryFile() - (rc, out, err) = get_jobs_file(module,user,tmpfile.name, cron_file) + if reboot: + if special_time: + module.fail_json(msg="reboot and special_time are mutually exclusive") + else: + special_time = "reboot" - if rc != 0 and rc != 1: # 1 can mean that there are no jobs. - module.fail_json(msg=err) + # if requested make a backup before making a change + if backup: + (backuph, backup_file) = tempfile.mkstemp(prefix='crontab') + crontab.write(backup_file) - (handle,backupfile) = tempfile.mkstemp(prefix='crontab') - (rc, out, err) = get_jobs_file(module,user,backupfile, cron_file) - if rc != 0 and rc != 1: - module.fail_json(msg=err) + if crontab.cron_file and not do_install: + crontab.remove_job_file() + changed = True + module.exit_json(changed=changed,cron_file=cron_file,state=state) + + job = crontab.get_cron_job(minute, hour, day, month, weekday, job, special_time) + old_job = crontab.find_job(name) - old_job = find_job(name,backupfile) if do_install: if len(old_job) == 0: - add_job(module,name,job,tmpfile.name) + crontab.add_job(name, job) changed = True if len(old_job) > 0 and old_job[1] != job: - update_job(name,job,tmpfile.name) + crontab.update_job(name, job) changed = True else: if len(old_job) > 0: - # if rm is true after the next line, file will be deleted afterwards - rm = remove_job(name,tmpfile.name) + crontab.remove_job(name) changed = True - else: - # there is no old_jobs for deletion - we should leave everything - # as is. If the file is empty, it will be removed later - tmpfile.close() - # the file created by mks should be deleted explicitly - os.unlink(backupfile) - module.exit_json(changed=changed,cron_file=cron_file,state=state) + + res_args = dict( + jobs = crontab.get_jobnames(), changed = changed + ) if changed: - # If the file is empty - remove it - if rm and cron_file: - remove_job_file(cron_file) + crontab.write() + + # retain the backup only if crontab or cron file have changed + if backup: + if changed: + res_args['backup_file'] = backup_file else: - if backup: - module.backup_local(backupfile) - (rc, out, err) = install_jobs(module,user,tmpfile.name, cron_file) - if (rc != 0): - module.fail_json(msg=err) - - # get the list of jobs in file - jobnames = [] - for j in get_jobs(tmpfile.name): - jobnames.append(j[0]) - tmpfile.close() - - if not backup: - os.unlink(backupfile) - module.exit_json(changed=changed,jobs=jobnames) - else: - module.exit_json(changed=changed,jobs=jobnames,backup=backupfile) + 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.") # include magic from lib/ansible/module_common.py #<>