Crontab is a well-known scheduling tool on Linux, and in Ansible, the cron module is used to manage crontab and environment variable entries.
- This module allows creating, updating, or deleting environment variables and named crontab entries.
- When managing crontab jobs, the module includes a line where the crontab entry description C(“#Ansible:”) corresponds to the “name” parameter passed to the module. This is used for future Ansible/module calls to locate/check the state.
- The “name” parameter should be unique, and changing the “name” value will result in creating a new cron task (or deleting others).
- When managing environment variables, no comment lines are added. However, the module uses the “name” parameter to locate the environment variable definition line when it needs to check the state.
Let’s first look at the imported modules.
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
The `platform` module provides many methods to retrieve operating system information, and the other modules are also commonly used. Next, the `CronTabError` exception class is defined.
class CronTab(object): 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()
The `CronTab` class is defined, and a `CronTab` object writes to time-based crontab files. The constructor takes two parameters: `user` (the crontab user, defaulting to the current user) and `cron_file` (a cron file under `/etc/cron.d` or an absolute path). It then calls the `read()` method to read the crontab from the system.
def read(self): self.lines = [] if self.cron_file: 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: return except Exception: raise CronTabError("Unexpected error:", sys.exc_info()[0]) else: (rc, out, err) = self.module.run_command(self._read_user_execute(), use_unsafe_shell=True) if rc != 0 and rc != 1: 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
The `lines` list is initialized as empty, and the cron file is read. If an `IOError` is raised (indicating the cron file does not exist), it returns empty. For other exceptions, the previously defined `CronTabError` is raised. `sys.exc_info()` is used to retrieve exception information.
During debugging, simply knowing the exception type is often insufficient. More detailed exception information is needed to resolve issues. There are two ways to capture more detailed exception information:
- Using the `exc_info` method from the `sys` module;
- Using related functions from the `traceback` module.
The `exc_info()` method returns the current exception information as a tuple containing three elements: `type`, `value`, and `traceback`. Their meanings are:
- `type`: The name of the exception type, which is a subclass of `BaseException`.
- `value`: The captured exception instance.
- `traceback`: A traceback object.
When `cron_file` is `None`, the `_read_user_execute()` method is executed as a command. The return code is checked, where non-zero and non-one values indicate an error. A return code of 1 means no jobs exist. If the crontab cannot be read, a `CronTabError` is raised. The command output is stored in `n_existing`.
The content of the crontab file is then processed line by line. When the line count exceeds 3 or specific comment lines are not matched, it is considered a crontab task. Otherwise, the line is escaped using `re.escape` and replaced with an empty string using `re.sub`.
The `_read_user_execute` method is called in the above code.
def _read_user_execute(self): 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')
This method returns the command line used to read the crontab. It generates the appropriate command based on different Linux operating systems.
def is_empty(self): if len(self.lines) == 0: return True else: return False
This is an example of poorly written code, which seems unprofessional and resembles something written by a beginner.
def is_empty(self): return len(self.lines) == 0
Wouldn’t it be better to write it like this? These two approaches are entirely equivalent!
def write(self, backup_file=None): 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() if backup_file: return if not self.cron_file: (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) if self.module.selinux_enabled() and self.cron_file: self.module.set_default_selinux_context(self.cron_file, False)
Writes the crontab to the system, saving all information. This primarily involves file I/O operations. If cron_file
is not specified, the method uses the command execution approach by invoking _write_execute
, which returns the appropriate operating system crontab command. It also checks whether SELinux is enabled.
def _write_execute(self, path): 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))
Returns the command-line string used to write the crontab. This is similar to the earlier _read_user_execute
method.
def render(self): crons = [] for cron in self.lines: crons.append(cron) result = '\n'.join(crons) if result: result = result.rstrip('\r\n') + '\n' return result
Renders the crontab as it would appear in the crontab file. Essentially, it joins each line in self.lines
with a newline character.
def do_comment(self, name): return "%s%s" % (self.ansible, name) def add_job(self, name, job): self.lines.append(self.do_comment(name)) 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
These methods are straightforward and handle adding or removing jobs in the crontab, as well as adding comments. For example, update_job
and remove_job
are wrappers around _update_job
, passing different parameters.
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
What is this env
? Let’s first look at a standard crontab file. At the beginning of the file, it specifies the shell and PATH being used.

The add_env
method supports two optional parameters: insertafter
and insertbefore
. If both are None
, the declaration is inserted at the beginning of the file (index 0). If either parameter is provided, it assigns the value to other_name
and calls find_env
to locate the corresponding environment variable. The find_env
method returns a two-element list, so the index of the match is at position 0. Depending on whether the insertion is before or after, the index is adjusted (+1 for after) and the declaration is inserted.
As for update_env
and remove_env
, they still call _update_env
with the appropriate parameters.
def find_env(self, name): for index, l in enumerate(self.lines): if re.match(r'^%s=' % name, l): return [index, l] return [] 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
The find_env
method iterates through self.lines
to find a match using a regular expression, returning the index and content of the match. Now, looking at _update_env
‘s addenvfunction
parameter, we can see that it is actually a function being passed as an argument—a callback function.
def remove_job_file(self): try: os.unlink(self.cron_file) return True except OSError: return False except Exception: raise CronTabError("Unexpected error:", sys.exc_info()[0]) def find_job(self, name, job=None): 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) if job: for i, l in enumerate(self.lines): if l == job: 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] 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 []
The remove_job_file
function, as the name suggests, deletes the crontab file. If the cron file does not exist, it returns False
. It attempts to locate a job by searching for comments starting with “Ansible:”. These comments are generated by Ansible’s cron module and act as markers. When a match is found, the “Ansible:” prefix is replaced with an empty string.
Otherwise, it tries to find the job through an exact match. Since some crontabs may not be written by the cron module, there might be no leading “Ansible:” header. In such cases, a header is inserted. If there is a blank “Ansible:” header and the job has a name, the header is updated.
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
In the get_cron_job
function, any leading or trailing newlines are normalized. This function formats the crontab entry based on the minute, hour, day, month, and weekday fields. The get_jobnames
function retrieves the names of the jobs. It uses re.sub(r'%s' % self.ansible, '', l)
to replace “#Ansible:” with an empty string, leaving only the job name. However, it’s worth questioning whether using re.sub
is necessary, as simple string slicing might achieve the same result. The get_envnames
function operates similarly to get_jobnames
, but it extracts environment variable names instead.
Next, let’s examine the main
function. The instantiation of the Ansible module object and parameter assignment will not be discussed in detail.
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_-]+
The script first retrieves the filename of cron_file
and uses a regular expression to check if it adheres to the naming convention. The os.umask(int('022', 8))
ensures that any generated files can only be written by the owning user. The permission 022
corresponds to 644
, which is primarily related to the cron_file
option. Then, the crontab object is instantiated, and parameters are validated.
- The
'name'
parameter will be required in future versions. - The
'reboot'
parameter will be deprecated. Users are advised to use the'special_time'
option instead.
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' 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.") 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"
The diff
dictionary stores changes made to the crontab before and after execution. The script then validates user input, such as ensuring that special_time
is not used on Solaris systems, as it is unsupported.
if backup and not module.check_mode: (backuph, backup_file) = tempfile.mkstemp(prefix='crontab') crontab.write(backup_file) if crontab.cron_file and not do_install: 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) 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
If necessary, make a backup before making changes. The do_install
parameter indicates whether the current operation is to add or remove a job, with the default being present
. Additionally, there are some method calls to the crontab
object, which were analyzed earlier in the class methods. Each job is also validated to ensure it does not contain newline characters.
Here’s a quick explanation of the differences between \r, \n, and \r\n:
On Windows:
\r
(Carriage Return): Moves the cursor to the beginning of the current line without advancing to the next line. If additional output is written, it will overwrite the existing content on that line.\n
(Line Feed): Moves the cursor to the next line without returning to the beginning of the line.
In Unix systems, the end of each line is marked with a “\n
” (Line Feed). In Windows systems, the end of each line is marked with “\r\n
” (Carriage Return + Line Feed). In older Mac systems, the end of each line is marked with “\r
” (Carriage Return). A direct consequence of this is that files created in Unix/Mac systems may appear as a single line when opened in Windows, while files created in Windows may display an extra ^M
character at the end of each line when opened in Unix/Mac systems.
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 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) module.exit_json(msg="Unable to execute cron task.")
No changes were made to the environment variables or jobs, but the existing crontab requires proper line termination. Backups are only retained if the crontab or cron file has been modified.
That concludes this explanation. Honestly, after reviewing the code for this cron module, I feel the code quality is not particularly high. When studying code from open-source projects, it’s important to approach it with a critical mindset. Think about why it’s written this way or whether you can come up with a better solution. This will help you improve more quickly.