Building a Ruby Cron Job Manager: Parse, Validate, and Safely Update Crontabs

DevOps / System Administration · Linux · Ruby 3.x

The problem this solves

Every team that manages more than a couple of servers eventually accumulates crontabs nobody fully understands: a mix of hand-edited entries from different engineers, no consistent formatting, and no record of which script added which line or why. Editing a crontab by hand with crontab -e is also just risky — there’s no built-in validation, no backup, and a typo in a schedule field either silently never fires or fires far more often than intended. This tutorial builds a small Ruby tool that manages crontab entries the way infrastructure-as-code manages servers: every entry it creates is tagged with a stable identifier, every schedule is validated before it’s written, and every write is preceded by an automatic backup — so cron_manager.rb can be safely called from deployment scripts without a human ever needing to run crontab -e.

Prerequisites

  • Ruby 2.7+ (tested on Ruby 3.0.2)
  • No external gems — only Ruby standard library (optparse, open3, fileutils)
  • The crontab command available if you want to manage the real system crontab (optional — the tool works equally well against a plain crontab-format file)

How it works

Ruby cron manager flow: crontab text to CrontabDocument to ScheduleValidator to backup and write, or reject on invalid schedule

  • CrontabSource — reads/writes either a plain file or the real per-user crontab via the crontab command, and takes a timestamped backup before every write
  • CrontabDocument — parses crontab text into managed entries, identified by a atop # cron_manager:<tag> comment placed directly above the job line
  • CronScheduleValidator — checks that a 5-field schedule has the right number of fields and that every value (including comma lists, ranges, and */N steps) falls within its valid range
  • CronManagerCLI — the list / validate / add / remove command-line interface tying it together

The complete code

Save this as cron_manager.rb:

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cron_manager.rb
#
# A safe, scriptable way to manage crontab entries from Ruby: list, validate,
# add, and remove jobs identified by a stable comment tag, with automatic
# backups before every write and dry-run support. Solves the "which script
# edited my crontab and why does line 14 look wrong" problem that comes from
# hand-editing crontabs with `crontab -e` across a fleet of servers.
#
# Usage:
#   ruby cron_manager.rb list --file mycrontab
#   ruby cron_manager.rb validate --file mycrontab
#   ruby cron_manager.rb add --file mycrontab --tag nightly_backup \
#        --schedule "15 2 * * *" --command "/opt/scripts/ruby_backup.rb"
#   ruby cron_manager.rb remove --file mycrontab --tag nightly_backup
#   ruby cron_manager.rb add ... --system   # operate on the real user crontab via `crontab`

require 'optparse'
require 'open3'
require 'time'
require 'fileutils'

# A single parsed cron line, tracking whether it's a managed entry (has our
# tag comment above it), a plain job, a comment, or blank.
CronEntry = Struct.new(:raw, :tag, :schedule, :command, keyword_init: true)

# Validates the 5-field schedule portion of a cron expression. This is not a
# full cron grammar implementation, but it catches the mistakes that most
# commonly break real crontabs: wrong field count and out-of-range values.
class CronScheduleValidator
  FIELD_RANGES = [(0..59), (0..23), (1..31), (1..12), (0..7)].freeze
  FIELD_NAMES = %w[minute hour day_of_month month day_of_week].freeze

  def self.validate(schedule)
    fields = schedule.strip.split(/\s+/)
    return ["expected 5 fields, got #{fields.size} (#{schedule.inspect})"] if fields.size != 5

    errors = []
    fields.each_with_index do |field, i|
      errors.concat(validate_field(field, i))
    end
    errors
  end

  def self.validate_field(field, index)
    return [] if field == '*'

    range = FIELD_RANGES[index]
    name = FIELD_NAMES[index]
    errors = []

    # Handle */N step syntax, comma lists, and simple ranges like 1-5.
    field.split(',').each do |part|
      part = part.sub(%r{^\*/}, '')
      part.split('-').each do |bound|
        next if bound.empty?
        n = Integer(bound, exception: false)
        if n.nil?
          errors << "#{name}: #{bound.inspect} is not a number"
        elsif !range.cover?(n)
          errors << "#{name}: #{n} is outside valid range #{range}"
        end
      end
    end
    errors
  end
end

# Reads and writes crontab-format text, either from a plain file or via the
# `crontab` command for the current user's real system crontab.
class CrontabSource
  def initialize(file: nil, system: false)
    raise ArgumentError, 'provide either file: or system: true' if file.nil? && !system

    @file = file
    @system = system
  end

  def read
    if @system
      out, _err, status = Open3.capture3('crontab', '-l')
      status.success? ? out : ''
    else
      File.exist?(@file) ? File.read(@file) : ''
    end
  end

  def write(content)
    backup!
    if @system
      IO.popen(['crontab', '-'], 'w') { |io| io.write(content) }
    else
      File.write(@file, content)
    end
  end

  private

  # Every write is preceded by a timestamped backup copy so a bad edit is
  # always one `cp` away from being undone.
  def backup!
    return unless @file && File.exist?(@file)

    backup_dir = File.join(File.dirname(@file), '.cron_manager_backups')
    FileUtils.mkdir_p(backup_dir)
    stamp = Time.now.utc.strftime('%Y%m%dT%H%M%SZ')
    FileUtils.cp(@file, File.join(backup_dir, "#{File.basename(@file)}.#{stamp}.bak"))
  end
end

# Parses managed entries out of raw crontab text. A managed entry looks like:
#   # cron_manager:nightly_backup
#   15 2 * * * /opt/scripts/ruby_backup.rb
TAG_COMMENT = /^# cron_manager:(\S+)\s*$/.freeze

class CrontabDocument
  def initialize(text)
    @lines = text.split("\n")
  end

  # Returns all managed entries as CronEntry structs.
  def managed_entries
    entries = []
    @lines.each_with_index do |line, i|
      m = TAG_COMMENT.match(line)
      next unless m

      job_line = @lines[i + 1]
      next unless job_line

      schedule, command = split_job_line(job_line)
      entries << CronEntry.new(raw: job_line, tag: m[1], schedule: schedule, command: command)
    end
    entries
  end

  def add_or_replace(tag, schedule, command)
    remove(tag) # replacing an existing tag keeps the file idempotent
    @lines << "# cron_manager:#{tag}"
    @lines << "#{schedule} #{command}"
    self
  end

  def remove(tag)
    out = []
    skip_next = false
    @lines.each do |line|
      if skip_next
        skip_next = false
        next
      end
      if TAG_COMMENT.match(line)&.then { |m| m[1] == tag }
        skip_next = true # also drop the job line right after the tag comment
        next
      end
      out << line
    end
    @lines = out
    self
  end

  def to_s
    @lines.join("\n") + "\n"
  end

  private

  def split_job_line(line)
    fields = line.strip.split(/\s+/)
    [fields.first(5).join(' '), fields.drop(5).join(' ')]
  end
end

# Ties everything together for the CLI.
class CronManagerCLI
  def initialize(argv)
    @options = { file: nil, system: false }
    @command = argv.shift
    parse!(argv)
    @source = CrontabSource.new(file: @options[:file], system: @options[:system])
  end

  def run
    case @command
    when 'list' then list
    when 'validate' then validate
    when 'add' then add
    when 'remove' then remove
    else
      warn "Unknown command: #{@command.inspect}. Use list, validate, add, or remove."
      exit 1
    end
  end

  private

  def parse!(argv)
    OptionParser.new do |opts|
      opts.on('--file PATH', 'Crontab-format file to operate on') { |v| @options[:file] = v }
      opts.on('--system', 'Operate on the real crontab for the current user via `crontab`') { @options[:system] = true }
      opts.on('--tag TAG', 'Stable identifier for a managed job') { |v| @options[:tag] = v }
      opts.on('--schedule EXPR', 'Cron schedule, e.g. "15 2 * * *"') { |v| @options[:schedule] = v }
      opts.on('--command CMD', 'Command line to run') { |v| @options[:command] = v }
      opts.on('--dry-run', 'Print what would change without writing') { @options[:dry_run] = true }
    end.parse!(argv)
  end

  def document
    CrontabDocument.new(@source.read)
  end

  def list
    entries = document.managed_entries
    if entries.empty?
      puts 'No managed cron_manager entries found.'
      return
    end
    entries.each { |e| puts "#{e.tag.ljust(20)} #{e.schedule.ljust(14)} #{e.command}" }
  end

  def validate
    errors_found = false
    document.managed_entries.each do |e|
      errors = CronScheduleValidator.validate(e.schedule)
      if errors.empty?
        puts "OK    #{e.tag}: #{e.schedule}"
      else
        errors_found = true
        puts "ERROR #{e.tag}: #{e.schedule}"
        errors.each { |msg| puts "        - #{msg}" }
      end
    end
    exit(1) if errors_found
  end

  def add
    require_options!(:tag, :schedule, :command)
    errors = CronScheduleValidator.validate(@options[:schedule])
    unless errors.empty?
      warn "Refusing to add invalid schedule #{@options[:schedule].inspect}:"
      errors.each { |e| warn "  - #{e}" }
      exit 1
    end

    doc = document.add_or_replace(@options[:tag], @options[:schedule], @options[:command])
    commit(doc, "add/update #{@options[:tag]}")
  end

  def remove
    require_options!(:tag)
    doc = document.remove(@options[:tag])
    commit(doc, "remove #{@options[:tag]}")
  end

  def commit(doc, description)
    if @options[:dry_run]
      puts "[dry-run] would #{description}. Resulting crontab:\n#{doc}"
      return
    end
    @source.write(doc.to_s)
    puts "Applied: #{description}"
  end

  def require_options!(*keys)
    missing = keys.select { |k| @options[k].nil? }
    return if missing.empty?

    warn "Missing required option(s): #{missing.map { |k| "--#{k}" }.join(', ')}"
    exit 1
  end
end

CronManagerCLI.new(ARGV).run if __FILE__ == $PROGRAM_NAME

Step-by-step walkthrough

1. Tagging entries instead of matching on raw text

Anatomy of a managed crontab entry: a cron_manager tag comment above the standard schedule and command line

Every entry the tool creates is preceded by a comment like # cron_manager:nightly_backup. CrontabDocument#managed_entries scans for that comment pattern and treats the line immediately below it as the job. This is what makes add idempotent (calling it twice with the same tag replaces the old entry instead of duplicating it) and makes remove precise (it deletes exactly the tagged block, leaving unrelated hand-written cron lines completely untouched).

2. Validating before writing, not after

CronScheduleValidator splits each of the 5 fields and checks values against their valid ranges (minutes 0–59, hours 0–23, and so on), handling comma lists, ranges, and */N step syntax. add calls this before touching the crontab at all — an invalid schedule like "99 2 * * *" is rejected outright rather than silently written and left to fail (or misfire) later.

3. A backup for every write, automatically

CrontabSource#backup! runs before any write to a file-backed crontab, copying the current contents into a .cron_manager_backups/ directory with a UTC timestamp in the filename. There’s no flag to opt out of this — the cost is one small file per write, and the benefit is that a bad automated change is always trivially recoverable.

4. File-based by default, real crontab when you’re ready

Every command accepts either --file PATH (recommended for testing and CI) or --system, which shells out to crontab -l / crontab - to operate on the real crontab for whichever user runs the script. Developing and testing against a plain file first, then switching to --system in production, avoids ever debugging cron logic against your live schedule.

Example output

Real output from running the tool against a test crontab file during development:

$ ruby cron_manager.rb add --file mycrontab --tag nightly_backup \
    --schedule "15 2 * * *" --command "/opt/scripts/ruby_backup.rb --source /etc/myapp --dest /var/backups/myapp"
Applied: add/update nightly_backup

$ ruby cron_manager.rb add --file mycrontab --tag log_watch \
    --schedule "99 2 * * *" --command "/opt/scripts/log_watcher.rb /var/log/nginx/access.log"
Refusing to add invalid schedule "99 2 * * *":
  - minute: 99 is outside valid range 0..59

$ ruby cron_manager.rb list --file mycrontab
nightly_backup       15 2 * * *     /opt/scripts/ruby_backup.rb --source /etc/myapp --dest /var/backups/myapp

$ ruby cron_manager.rb add --file mycrontab --tag log_watch \
    --schedule "*/5 * * * *" --command "/opt/scripts/log_watcher.rb /var/log/nginx/access.log"
Applied: add/update log_watch

$ ruby cron_manager.rb validate --file mycrontab
OK    nightly_backup: 15 2 * * *
OK    log_watch: */5 * * * *

$ ruby cron_manager.rb remove --file mycrontab --tag nightly_backup --dry-run
[dry-run] would remove nightly_backup. Resulting crontab:
# existing unrelated job
0 4 * * * /usr/bin/some_other_script.sh
# cron_manager:log_watch
*/5 * * * * /opt/scripts/log_watcher.rb /var/log/nginx/access.log

Notice the invalid schedule was rejected before anything was written, an unrelated hand-written cron line (0 4 * * * /usr/bin/some_other_script.sh) survived every operation untouched, and the dry-run preview matched exactly what a real remove produced.

Wiring it into a deployment script

# in your deploy script, after copying ruby_backup.rb into place:
ruby /opt/scripts/cron_manager.rb add --system \
  --tag nightly_backup \
  --schedule "15 2 * * *" \
  --command "/opt/scripts/ruby_backup.rb --source /etc/myapp --dest /var/backups/myapp --keep 14"

Because add is idempotent by tag, this line is safe to run on every deploy — it will either create the job or update it in place if the schedule/command changed, with zero risk of duplicate entries piling up over time.

Troubleshooting

Symptom Likely cause / fix
list shows nothing even though the crontab has entries Only entries preceded by a # cron_manager:<tag> comment are “managed” and shown; hand-written cron lines are intentionally left alone and invisible to this tool.
--system writes appear to do nothing Confirm the user running the script actually has crontab permissions (check /etc/cron.allow / /etc/cron.deny), and that crontab -l works manually first.
Validator rejects a schedule you’re sure is valid Check for extra whitespace or a 6th field (some cron variants support a seconds field, which this validator doesn’t) — it strictly expects 5 space-separated fields.
Backups directory grows unbounded Add your own retention sweep over .cron_manager_backups/ (the RetentionPolicy pattern from a companion backup-automation script applies directly here).
remove didn’t remove anything Double check the exact --tag spelling with list first — tags are matched exactly, case-sensitively.

Extending this script

  • Load job definitions from a YAML file so a whole fleet’s cron schedule can be declared and applied in one --sync pass instead of one add per job
  • Add a diff command that shows exactly what would change (add/remove/modify) without writing, for use in a CI dry-run step
  • Validate that referenced command paths actually exist and are executable before allowing an add
  • Support day-of-week and month name aliases (MON, JAN) in the validator, matching what real cron daemons accept
  • Add a --prune-backups-older-than N flag to CrontabSource to cap backup retention automatically

Leave a Reply

Your email address will not be published. Required fields are marked *