the shed // linux // cron & scheduling

Hand-editing crontab -e on a shared box is one typo away from a bad afternoon. Here’s a small, idempotent Ruby wrapper that lets scripts safely add, update, and remove their own cron entries — without ever touching anyone else’s.

Step through the build below:




cron_job_manager.rb

Hand-editing a shared server’s crontab is one typo away from a bad afternoon.
crontab -e opens the whole file for editing, and there’s nothing stopping you from fat-fingering a
field, duplicating a job that’s already there, or — worse — overwriting someone else’s entries entirely if two
deploy scripts race to write a new crontab at the same time. It’s also awkward to manage cron jobs
programmatically: a provisioning script that wants to “make sure the nightly backup job exists” has no
clean way to check, add, or update just its own entry without touching anything else.

This script solves both problems. It tags every entry it creates with a uniquely-ID’d marker comment, so
add/update/remove operations only ever touch the block they own — everything else in the crontab, including jobs
nobody remembers who set up, is left completely untouched. And because add is idempotent (re-running
it for the same ID updates in place instead of duplicating), it’s safe to call from a provisioning script or
Ansible-style playbook every time it runs, not just the first time.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cron_job_manager.rb
#
# Safely list, add, update, and remove crontab entries from scripts --
# without ever hand-editing `crontab -e` or risking a typo that wipes out
# someone else's jobs. Every entry this tool writes is wrapped in a
# uniquely-tagged marker block, so add/remove/update only ever touch the
# lines they own; everything else in the crontab is left byte-for-byte
# untouched. Schedules are validated against cron grammar *before* they
# ever reach `crontab -`, so a bad schedule fails loudly in Ruby instead
# of silently corrupting the user's crontab.
#
# Usage:
#   ruby cron_job_manager.rb list [--json]
#   ruby cron_job_manager.rb add    --id ID --schedule "SCHED" --command "CMD" [--dry-run]
#   ruby cron_job_manager.rb remove --id ID [--dry-run]
#
# Options common to all subcommands:
#   --crontab-bin PATH   Path to the crontab binary (default: "crontab").
#                         Overridable so this can be pointed at a stub for
#                         testing, or at a wrapper for a specific user.
#
require 'optparse'
require 'json'
require 'open3'
MARKER_BEGIN = 'BEGIN cron_job_manager'
MARKER_END = 'END cron_job_manager'
# ---------------------------------------------------------------------------
# CronValidator: pure-function validation of the 5 standard cron fields
# (minute hour day-of-month month day-of-week). Deliberately conservative --
# it accepts the common syntaxes (*, N, N-M, N,M,O, */N, N-M/S) and rejects
# anything else with a specific reason, rather than trying to be a full
# cron-grammar parser.
# ---------------------------------------------------------------------------
module CronValidator
  RANGES = [
    (0..59), # minute
    (0..23), # hour
    (1..31), # day of month
    (1..12), # month
    (0..7)   # day of week (0 and 7 both == Sunday)
  ].freeze
  FIELD_NAMES = %w[minute hour day-of-month month day-of-week].freeze
  # Returns nil if the schedule is valid, or a human-readable error string.
  def self.validate(schedule)
    fields = schedule.strip.split(/\s+/)
    return "expected 5 fields (minute hour dom month dow), got #{fields.size}: #{schedule.inspect}" unless fields.size == 5
    fields.each_with_index do |field, idx|
      err = validate_field(field, RANGES[idx])
      return "field #{idx + 1} (#{FIELD_NAMES[idx]}) invalid: #{err} in #{field.inspect}" if err
    end
    nil
  end
  def self.validate_field(field, range)
    field.split(',').each do |part|
      base, step = part.split('/', 2)
      if step && step !~ /\A\d+\z/
        return 'step must be a positive integer'
      end
      case base
      when '*'
        next
      when /\A\d+\z/
        return "value #{base} out of range #{range}" unless range.cover?(base.to_i)
      when /\A(\d+)-(\d+)\z/
        lo, hi = Regexp.last_match(1).to_i, Regexp.last_match(2).to_i
        return "range #{lo}-#{hi} out of bounds #{range}" unless range.cover?(lo) && range.cover?(hi)
        return "range start #{lo} is greater than end #{hi}" if lo > hi
      else
        return "unrecognized token #{base.inspect}"
      end
    end
    nil
  end
end
# ---------------------------------------------------------------------------
# CrontabIO: thin wrapper around `crontab -l` / `crontab -` so the rest of
# the tool never shells out directly. Missing crontab (no entries yet) is
# treated as an empty crontab rather than an error, matching real-world
# `crontab -l` behavior (exit code 1, "no crontab for user" on stderr).
# ---------------------------------------------------------------------------
class CrontabIO
  class Error < StandardError; end
  def initialize(crontab_bin: 'crontab')
    @crontab_bin = crontab_bin
  end
  def read
    out, err, status = Open3.capture3(@crontab_bin, '-l')
    return out if status.success?
    return '' if err =~ /no crontab for/i
    raise Error, "`#{@crontab_bin} -l` failed: #{err.strip}"
  end
  def write(contents)
    out, err, status = Open3.capture3(@crontab_bin, '-', stdin_data: contents)
    raise Error, "`#{@crontab_bin} -` failed: #{err.strip} #{out.strip}" unless status.success?
    true
  end
end
# ---------------------------------------------------------------------------
# ManagedCrontab: parses the raw crontab text into a list of "blocks"
# (either a managed block we can address by id, or an opaque passthrough
# block of everything else), and knows how to add/update/remove a managed
# block by id while preserving line order and untouched content exactly.
# ---------------------------------------------------------------------------
class ManagedCrontab
  ManagedEntry = Struct.new(:id, :schedule, :command, keyword_init: true)
  def initialize(raw_text)
    @lines = raw_text.split("\n")
  end
  def managed_entries
    entries = []
    @lines.each_with_index do |line, idx|
      next unless line.include?(MARKER_BEGIN)
      id = line[/#{MARKER_BEGIN}:(\S+)/, 1]
      body = @lines[idx + 1]
      next unless body
      schedule = body.split(/\s+/, 6)[0, 5].join(' ')
      command = body.split(/\s+/, 6)[5]
      entries << ManagedEntry.new(id: id, schedule: schedule, command: command)
    end
    entries
  end
  # Adds a new managed entry, or replaces the existing one with the same id
  # in place (so re-running `add` for the same id is idempotent and doesn't
  # duplicate or reorder entries).
  def upsert(id, schedule, command)
    block = ["# #{MARKER_BEGIN}:#{id}", "#{schedule} #{command}", "# #{MARKER_END}:#{id}"]
    start_idx = find_block_start(id)
    if start_idx
      end_idx = find_block_end(id, start_idx)
      @lines[start_idx..end_idx] = block
    else
      @lines << "# #{MARKER_BEGIN}:#{id}"
      @lines << "#{schedule} #{command}"
      @lines << "# #{MARKER_END}:#{id}"
    end
    self
  end
  # Removes a managed entry by id. Returns true if something was removed.
  def remove(id)
    start_idx = find_block_start(id)
    return false unless start_idx
    end_idx = find_block_end(id, start_idx)
    @lines.slice!(start_idx..end_idx)
    true
  end
  def to_s
    @lines.join("\n") + "\n"
  end
  private
  def find_block_start(id)
    @lines.index { |l| l.include?("#{MARKER_BEGIN}:#{id}") }
  end
  def find_block_end(id, start_idx)
    idx = @lines[start_idx..].index { |l| l.include?("#{MARKER_END}:#{id}") }
    idx ? start_idx + idx : start_idx + 2
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def run_list(crontab_io, json:)
  entries = ManagedCrontab.new(crontab_io.read).managed_entries
  if json
    puts JSON.pretty_generate(entries.map(&:to_h))
  elsif entries.empty?
    puts 'No cron_job_manager-managed entries found.'
  else
    printf("%-24s %-16s %s\n", 'ID', 'SCHEDULE', 'COMMAND')
    puts '-' * 80
    entries.each { |e| printf("%-24s %-16s %s\n", e.id, e.schedule, e.command) }
  end
  0
end
def run_add(crontab_io, id:, schedule:, command:, dry_run:)
  if (err = CronValidator.validate(schedule))
    warn "error: invalid schedule -- #{err}"
    return 1
  end
  if id.nil? || id.strip.empty?
    warn 'error: --id is required'
    return 1
  end
  if command.nil? || command.strip.empty?
    warn 'error: --command is required'
    return 1
  end
  managed = ManagedCrontab.new(crontab_io.read)
  action = managed.managed_entries.any? { |e| e.id == id } ? 'update' : 'add'
  managed.upsert(id, schedule, command)
  if dry_run
    puts "[dry-run] would #{action} entry '#{id}': #{schedule} #{command}"
  else
    crontab_io.write(managed.to_s)
    puts "#{action == 'update' ? 'Updated' : 'Added'} entry '#{id}': #{schedule} #{command}"
  end
  0
end
def run_remove(crontab_io, id:, dry_run:)
  if id.nil? || id.strip.empty?
    warn 'error: --id is required'
    return 1
  end
  managed = ManagedCrontab.new(crontab_io.read)
  found = managed.managed_entries.any? { |e| e.id == id }
  unless found
    warn "error: no managed entry with id '#{id}' found"
    return 1
  end
  if dry_run
    puts "[dry-run] would remove entry '#{id}'"
  else
    managed.remove(id)
    crontab_io.write(managed.to_s)
    puts "Removed entry '#{id}'"
  end
  0
end
if __FILE__ == $PROGRAM_NAME
  options = { crontab_bin: 'crontab', dry_run: false, json: false }
  subcommand = ARGV.shift
  parser = OptionParser.new do |opts|
    opts.banner = 'Usage: ruby cron_job_manager.rb {list|add|remove} [options]'
    opts.on('--id ID', 'Unique identifier for the managed entry') { |v| options[:id] = v }
    opts.on('--schedule SCHED', 'Cron schedule, 5 fields, e.g. "0 2 * * *"') { |v| options[:schedule] = v }
    opts.on('--command CMD', 'Command line to run') { |v| options[:command] = v }
    opts.on('--crontab-bin PATH', 'Path to crontab binary (default: crontab)') { |v| options[:crontab_bin] = v }
    opts.on('--dry-run', 'Preview the change without writing the crontab') { options[:dry_run] = true }
    opts.on('--json', 'JSON output for `list`') { options[:json] = true }
  end
  parser.parse!(ARGV)
  crontab_io = CrontabIO.new(crontab_bin: options[:crontab_bin])
  status =
    begin
      case subcommand
      when 'list'
        run_list(crontab_io, json: options[:json])
      when 'add'
        run_add(crontab_io, id: options[:id], schedule: options[:schedule],
                             command: options[:command], dry_run: options[:dry_run])
      when 'remove'
        run_remove(crontab_io, id: options[:id], dry_run: options[:dry_run])
      else
        warn parser
        1
      end
    rescue CrontabIO::Error => e
      warn "error: #{e.message}"
      2
    end
  exit status
end

The core idea is a text transformation, not really a “cron” problem: parse
crontab -l output into a list of lines, find or create a three-line block bounded by
# BEGIN cron_job_manager:<id> / # END cron_job_manager:<id> comments, and
write the whole thing back with crontab - (which replaces the entire crontab from stdin). Because the
markers are just comments, cron itself ignores them completely — they’re purely bookkeeping for this tool.

Validation happens before anything is written. CronValidator checks each of the 5 fields
against cron’s real grammar (*, a bare number, a range like 1-5, a comma list, and
/step suffixes) and against the legal range for that field (0-59 for minutes, 1-12 for months, and so
on). A schedule that fails validation returns a specific, actionable error and exits non-zero without ever calling
crontab -l or crontab - — so a typo can’t corrupt anything, it just gets rejected.

The one thing worth flagging honestly: crontab -l/crontab - aren’t atomic against a
concurrent human running crontab -e at the exact same moment — that’s a real limitation of the
crontab command itself, not something this script’s design papers over. For high-contention environments, wrap
calls to this script in your own file lock (e.g. flock) around the section that reads and rewrites.

=== list (empty of managed entries) ===
No cron_job_manager-managed entries found.
=== add nightly-backup ===
Added entry 'nightly-backup': 0 2 * * * /usr/local/bin/backup.sh
=== add invalid schedule (should fail, exit 1) ===
error: invalid schedule -- field 1 (minute) invalid: value 99 out of range 0..59 in "99"
exit=1
=== add weekly-report ===
Added entry 'weekly-report': 0 9 * * 1 /usr/local/bin/report.sh --weekly
=== list (json) ===
[
  { "id": "nightly-backup", "schedule": "0 2 * * *", "command": "/usr/local/bin/backup.sh" },
  { "id": "weekly-report",  "schedule": "0 9 * * 1", "command": "/usr/local/bin/report.sh --weekly" }
]
=== update nightly-backup (idempotent upsert, change time) ===
Updated entry 'nightly-backup': 30 2 * * * /usr/local/bin/backup.sh --verbose
=== raw crontab contents after all writes ===
# unrelated pre-existing entry, should never be touched
17 4 * * * /usr/local/bin/some-other-job.sh
# BEGIN cron_job_manager:nightly-backup
30 2 * * * /usr/local/bin/backup.sh --verbose
# END cron_job_manager:nightly-backup
# BEGIN cron_job_manager:weekly-report
0 9 * * 1 /usr/local/bin/report.sh --weekly
# END cron_job_manager:weekly-report
=== dry-run remove weekly-report ===
[dry-run] would remove entry 'weekly-report'
=== remove weekly-report (real) ===
Removed entry 'weekly-report'
=== raw crontab contents after remove (unrelated entry + nightly-backup only) ===
# unrelated pre-existing entry, should never be touched
17 4 * * * /usr/local/bin/some-other-job.sh
# BEGIN cron_job_manager:nightly-backup
30 2 * * * /usr/local/bin/backup.sh --verbose
# END cron_job_manager:nightly-backup
=== remove nonexistent id (should fail, exit 1) ===
error: no managed entry with id 'does-not-exist' found
exit=1
Get the code

Full script + README on GitHub: ruby-devops-toolkit/cron-job-manager

architecture

How it fits together

Diagram: crontab -l is parsed into a ManagedCrontab model of tagged blocks vs. passthrough lines, schedules are validated before upsert/remove, and the whole file is written back atomically via crontab -

Tagged-block upsert/remove flow
prerequisites

Prerequisites

What you need
  • Ruby 3.0 or newer (tested on 3.0.2); Ruby 2.5+ should work unmodified.
  • No gems. Uses only optparse, json, and open3 from the
    standard library.
  • The crontab command available on PATH (standard on any Linux/macOS
    box with cron installed). The binary path is overridable via --crontab-bin, which is also how the
    test suite points it at a stub for CI.
  • Platform: Linux or macOS. Windows doesn’t have crontab — see the companion
    “scheduled-task-audit” tool in this toolkit for the Windows Task Scheduler equivalent.
reference

The full script

cron_job_manager.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# cron_job_manager.rb
#
# Safely list, add, update, and remove crontab entries from scripts --
# without ever hand-editing `crontab -e` or risking a typo that wipes out
# someone else's jobs. Every entry this tool writes is wrapped in a
# uniquely-tagged marker block, so add/remove/update only ever touch the
# lines they own; everything else in the crontab is left byte-for-byte
# untouched. Schedules are validated against cron grammar *before* they
# ever reach `crontab -`, so a bad schedule fails loudly in Ruby instead
# of silently corrupting the user's crontab.
#
# Usage:
#   ruby cron_job_manager.rb list [--json]
#   ruby cron_job_manager.rb add    --id ID --schedule "SCHED" --command "CMD" [--dry-run]
#   ruby cron_job_manager.rb remove --id ID [--dry-run]
#
# Options common to all subcommands:
#   --crontab-bin PATH   Path to the crontab binary (default: "crontab").
#                         Overridable so this can be pointed at a stub for
#                         testing, or at a wrapper for a specific user.
#
require 'optparse'
require 'json'
require 'open3'
MARKER_BEGIN = 'BEGIN cron_job_manager'
MARKER_END = 'END cron_job_manager'
# ---------------------------------------------------------------------------
# CronValidator: pure-function validation of the 5 standard cron fields
# (minute hour day-of-month month day-of-week). Deliberately conservative --
# it accepts the common syntaxes (*, N, N-M, N,M,O, */N, N-M/S) and rejects
# anything else with a specific reason, rather than trying to be a full
# cron-grammar parser.
# ---------------------------------------------------------------------------
module CronValidator
  RANGES = [
    (0..59), # minute
    (0..23), # hour
    (1..31), # day of month
    (1..12), # month
    (0..7)   # day of week (0 and 7 both == Sunday)
  ].freeze
  FIELD_NAMES = %w[minute hour day-of-month month day-of-week].freeze
  # Returns nil if the schedule is valid, or a human-readable error string.
  def self.validate(schedule)
    fields = schedule.strip.split(/\s+/)
    return "expected 5 fields (minute hour dom month dow), got #{fields.size}: #{schedule.inspect}" unless fields.size == 5
    fields.each_with_index do |field, idx|
      err = validate_field(field, RANGES[idx])
      return "field #{idx + 1} (#{FIELD_NAMES[idx]}) invalid: #{err} in #{field.inspect}" if err
    end
    nil
  end
  def self.validate_field(field, range)
    field.split(',').each do |part|
      base, step = part.split('/', 2)
      if step && step !~ /\A\d+\z/
        return 'step must be a positive integer'
      end
      case base
      when '*'
        next
      when /\A\d+\z/
        return "value #{base} out of range #{range}" unless range.cover?(base.to_i)
      when /\A(\d+)-(\d+)\z/
        lo, hi = Regexp.last_match(1).to_i, Regexp.last_match(2).to_i
        return "range #{lo}-#{hi} out of bounds #{range}" unless range.cover?(lo) && range.cover?(hi)
        return "range start #{lo} is greater than end #{hi}" if lo > hi
      else
        return "unrecognized token #{base.inspect}"
      end
    end
    nil
  end
end
# ---------------------------------------------------------------------------
# CrontabIO: thin wrapper around `crontab -l` / `crontab -` so the rest of
# the tool never shells out directly. Missing crontab (no entries yet) is
# treated as an empty crontab rather than an error, matching real-world
# `crontab -l` behavior (exit code 1, "no crontab for user" on stderr).
# ---------------------------------------------------------------------------
class CrontabIO
  class Error < StandardError; end
  def initialize(crontab_bin: 'crontab')
    @crontab_bin = crontab_bin
  end
  def read
    out, err, status = Open3.capture3(@crontab_bin, '-l')
    return out if status.success?
    return '' if err =~ /no crontab for/i
    raise Error, "`#{@crontab_bin} -l` failed: #{err.strip}"
  end
  def write(contents)
    out, err, status = Open3.capture3(@crontab_bin, '-', stdin_data: contents)
    raise Error, "`#{@crontab_bin} -` failed: #{err.strip} #{out.strip}" unless status.success?
    true
  end
end
# ---------------------------------------------------------------------------
# ManagedCrontab: parses the raw crontab text into a list of "blocks"
# (either a managed block we can address by id, or an opaque passthrough
# block of everything else), and knows how to add/update/remove a managed
# block by id while preserving line order and untouched content exactly.
# ---------------------------------------------------------------------------
class ManagedCrontab
  ManagedEntry = Struct.new(:id, :schedule, :command, keyword_init: true)
  def initialize(raw_text)
    @lines = raw_text.split("\n")
  end
  def managed_entries
    entries = []
    @lines.each_with_index do |line, idx|
      next unless line.include?(MARKER_BEGIN)
      id = line[/#{MARKER_BEGIN}:(\S+)/, 1]
      body = @lines[idx + 1]
      next unless body
      schedule = body.split(/\s+/, 6)[0, 5].join(' ')
      command = body.split(/\s+/, 6)[5]
      entries << ManagedEntry.new(id: id, schedule: schedule, command: command)
    end
    entries
  end
  # Adds a new managed entry, or replaces the existing one with the same id
  # in place (so re-running `add` for the same id is idempotent and doesn't
  # duplicate or reorder entries).
  def upsert(id, schedule, command)
    block = ["# #{MARKER_BEGIN}:#{id}", "#{schedule} #{command}", "# #{MARKER_END}:#{id}"]
    start_idx = find_block_start(id)
    if start_idx
      end_idx = find_block_end(id, start_idx)
      @lines[start_idx..end_idx] = block
    else
      @lines << "# #{MARKER_BEGIN}:#{id}"
      @lines << "#{schedule} #{command}"
      @lines << "# #{MARKER_END}:#{id}"
    end
    self
  end
  # Removes a managed entry by id. Returns true if something was removed.
  def remove(id)
    start_idx = find_block_start(id)
    return false unless start_idx
    end_idx = find_block_end(id, start_idx)
    @lines.slice!(start_idx..end_idx)
    true
  end
  def to_s
    @lines.join("\n") + "\n"
  end
  private
  def find_block_start(id)
    @lines.index { |l| l.include?("#{MARKER_BEGIN}:#{id}") }
  end
  def find_block_end(id, start_idx)
    idx = @lines[start_idx..].index { |l| l.include?("#{MARKER_END}:#{id}") }
    idx ? start_idx + idx : start_idx + 2
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def run_list(crontab_io, json:)
  entries = ManagedCrontab.new(crontab_io.read).managed_entries
  if json
    puts JSON.pretty_generate(entries.map(&:to_h))
  elsif entries.empty?
    puts 'No cron_job_manager-managed entries found.'
  else
    printf("%-24s %-16s %s\n", 'ID', 'SCHEDULE', 'COMMAND')
    puts '-' * 80
    entries.each { |e| printf("%-24s %-16s %s\n", e.id, e.schedule, e.command) }
  end
  0
end
def run_add(crontab_io, id:, schedule:, command:, dry_run:)
  if (err = CronValidator.validate(schedule))
    warn "error: invalid schedule -- #{err}"
    return 1
  end
  if id.nil? || id.strip.empty?
    warn 'error: --id is required'
    return 1
  end
  if command.nil? || command.strip.empty?
    warn 'error: --command is required'
    return 1
  end
  managed = ManagedCrontab.new(crontab_io.read)
  action = managed.managed_entries.any? { |e| e.id == id } ? 'update' : 'add'
  managed.upsert(id, schedule, command)
  if dry_run
    puts "[dry-run] would #{action} entry '#{id}': #{schedule} #{command}"
  else
    crontab_io.write(managed.to_s)
    puts "#{action == 'update' ? 'Updated' : 'Added'} entry '#{id}': #{schedule} #{command}"
  end
  0
end
def run_remove(crontab_io, id:, dry_run:)
  if id.nil? || id.strip.empty?
    warn 'error: --id is required'
    return 1
  end
  managed = ManagedCrontab.new(crontab_io.read)
  found = managed.managed_entries.any? { |e| e.id == id }
  unless found
    warn "error: no managed entry with id '#{id}' found"
    return 1
  end
  if dry_run
    puts "[dry-run] would remove entry '#{id}'"
  else
    managed.remove(id)
    crontab_io.write(managed.to_s)
    puts "Removed entry '#{id}'"
  end
  0
end
if __FILE__ == $PROGRAM_NAME
  options = { crontab_bin: 'crontab', dry_run: false, json: false }
  subcommand = ARGV.shift
  parser = OptionParser.new do |opts|
    opts.banner = 'Usage: ruby cron_job_manager.rb {list|add|remove} [options]'
    opts.on('--id ID', 'Unique identifier for the managed entry') { |v| options[:id] = v }
    opts.on('--schedule SCHED', 'Cron schedule, 5 fields, e.g. "0 2 * * *"') { |v| options[:schedule] = v }
    opts.on('--command CMD', 'Command line to run') { |v| options[:command] = v }
    opts.on('--crontab-bin PATH', 'Path to crontab binary (default: crontab)') { |v| options[:crontab_bin] = v }
    opts.on('--dry-run', 'Preview the change without writing the crontab') { options[:dry_run] = true }
    opts.on('--json', 'JSON output for `list`') { options[:json] = true }
  end
  parser.parse!(ARGV)
  crontab_io = CrontabIO.new(crontab_bin: options[:crontab_bin])
  status =
    begin
      case subcommand
      when 'list'
        run_list(crontab_io, json: options[:json])
      when 'add'
        run_add(crontab_io, id: options[:id], schedule: options[:schedule],
                             command: options[:command], dry_run: options[:dry_run])
      when 'remove'
        run_remove(crontab_io, id: options[:id], dry_run: options[:dry_run])
      else
        warn parser
        1
      end
    rescue CrontabIO::Error => e
      warn "error: #{e.message}"
      2
    end
  exit status
end
walkthrough

Step-by-step walkthrough

1. CronValidator — reject bad schedules before they’re written

Each of the 5 cron fields is checked independently against its legal range and syntax. Comma-separated lists are split and each part validated on its own, so "1,15,30" or "*/15" both work, while something like "99 2 * * *" (minute out of range) fails immediately with a message that names the exact field and why.

2. CrontabIO — a thin, swappable wrapper around the real command

All interaction with the system goes through two calls: crontab -l to read (treating “no crontab for user” as an empty crontab rather than an error), and crontab - to write the entire new contents from stdin via Open3.capture3. Nothing else in the script talks to the OS directly, which is what makes it possible to point --crontab-bin at a stub executable for testing without touching a real crontab at all.

3. ManagedCrontab — tagged blocks, not line numbers

Rather than remembering “my entry was on line 7”, every managed entry is wrapped in comment markers carrying its ID. upsert searches for an existing block with that ID and replaces just those three lines in place if found (preserving the entry’s position and everything around it), or appends a new block if not. remove does the same lookup and deletes the whole block. Every other line in the crontab — including entries this tool never created — is copied through completely untouched.

4. Idempotent by design

Running add --id nightly-backup ... twice with different values doesn’t create a duplicate entry; the second call finds the existing block by ID and updates it in place. That makes it safe to call from a provisioning script on every deploy, the same way you’d want a Chef/Puppet/Ansible resource to converge to the desired state rather than accumulate drift.

output

Example output

cron_job_manager.rb — test run against a stub crontab
$ ruby cron_job_manager.rb add –id nightly-backup –schedule "0 2 * * *" \
–command "/usr/local/bin/backup.sh"
Added entry 'nightly-backup': 0 2 * * * /usr/local/bin/backup.sh
$ ruby cron_job_manager.rb add –id bad –schedule "99 2 * * *" –command "/bin/true"
error: invalid schedule — field 1 (minute) invalid: value 99 out of range 0..59
exit=1
$ ruby cron_job_manager.rb remove –id weekly-report
Removed entry 'weekly-report'

The full run above (list / add / invalid add / update / list –json / remove –dry-run / remove) was captured against a stub crontab shell script that reads and writes a plain file instead of a real system crontab — see test/fake_crontab in the GitHub folder. The stub speaks the exact same -l / - interface as the real binary, so the Ruby code under test is identical to what runs against a real crontab.

troubleshooting

Troubleshooting

Common issues
  • crontab -l failed: you (username) are not allowed to use this program:
    this is /etc/cron.allow / /etc/cron.deny restricting who can use cron on this box —
    that’s a system policy decision, not something the script can work around.
  • Entries seem to vanish after a system cron package update: some distros migrate crontabs
    during upgrades; run list right after any OS/cron package update to confirm your managed entries
    survived.
  • Two automation runs stomp on each other’s changes: crontab -l +
    crontab - is a read-then-write, not a single atomic operation — wrap calls in flock
    if multiple processes might modify the same user’s crontab concurrently.
  • Want to manage another user’s crontab: real crontab supports -u user,
    but that requires root. Point --crontab-bin at a small wrapper script that calls
    crontab -u thatuser "$@" if you need this.
extending

Extending this script

Ideas
  • Declarative mode: accept a YAML file listing all desired managed entries and reconcile in one
    call — add missing ones, update changed ones, remove managed entries no longer in the file (never touching
    unmanaged lines).
  • Dry-run diff output: extend --dry-run to print a unified diff of the crontab
    before/after, so a CI pipeline can post it for human review before applying.
  • Timezone-aware scheduling: cron itself runs in the system timezone; add a
    --validate-tz helper that warns when a schedule looks like it was written assuming a different
    timezone than the target host’s.
  • Windows counterpart: pair this with the toolkit’s scheduled-task-audit script
    (WIN32OLE + Task Scheduler) for a cross-platform “what’s scheduled to run on this fleet” inventory.