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:
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
Full script + README on GitHub: ruby-devops-toolkit/cron-job-manager
How it fits together
Prerequisites
- Ruby 3.0 or newer (tested on 3.0.2); Ruby 2.5+ should work unmodified.
- No gems. Uses only
optparse,json, andopen3from the
standard library. - The
crontabcommand available onPATH(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.
The full script
#!/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
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.
Example output
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
crontab -l failed: you (username) are not allowed to use this program:
this is/etc/cron.allow//etc/cron.denyrestricting 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; runlistright 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 inflock
if multiple processes might modify the same user’s crontab concurrently. - Want to manage another user’s crontab: real
crontabsupports-u user,
but that requires root. Point--crontab-binat a small wrapper script that calls
crontab -u thatuser "$@"if you need this.
Extending this script
- 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-runto 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-tzhelper 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-auditscript
(WIN32OLE + Task Scheduler) for a cross-platform “what’s scheduled to run on this fleet” inventory.