whenever-benlangfeld 0.9.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +6 -0
  3. data/.travis.yml +18 -0
  4. data/CHANGELOG.md +333 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE +22 -0
  7. data/README.md +260 -0
  8. data/Rakefile +10 -0
  9. data/bin/whenever +41 -0
  10. data/bin/wheneverize +68 -0
  11. data/gemfiles/activesupport4.1.gemfile +5 -0
  12. data/gemfiles/activesupport4.2.gemfile +5 -0
  13. data/lib/whenever.rb +34 -0
  14. data/lib/whenever/capistrano.rb +7 -0
  15. data/lib/whenever/capistrano/v2/hooks.rb +8 -0
  16. data/lib/whenever/capistrano/v2/recipes.rb +48 -0
  17. data/lib/whenever/capistrano/v2/support.rb +53 -0
  18. data/lib/whenever/capistrano/v3/tasks/whenever.rake +45 -0
  19. data/lib/whenever/command_line.rb +135 -0
  20. data/lib/whenever/cron.rb +153 -0
  21. data/lib/whenever/job.rb +54 -0
  22. data/lib/whenever/job_list.rb +155 -0
  23. data/lib/whenever/numeric.rb +13 -0
  24. data/lib/whenever/numeric_seconds.rb +48 -0
  25. data/lib/whenever/os.rb +7 -0
  26. data/lib/whenever/output_redirection.rb +57 -0
  27. data/lib/whenever/setup.rb +26 -0
  28. data/lib/whenever/tasks/whenever.rake +1 -0
  29. data/lib/whenever/version.rb +3 -0
  30. data/test/functional/command_line_test.rb +331 -0
  31. data/test/functional/output_at_test.rb +207 -0
  32. data/test/functional/output_default_defined_jobs_test.rb +296 -0
  33. data/test/functional/output_defined_job_test.rb +85 -0
  34. data/test/functional/output_env_test.rb +29 -0
  35. data/test/functional/output_jobs_for_roles_test.rb +65 -0
  36. data/test/functional/output_redirection_test.rb +248 -0
  37. data/test/test_case.rb +32 -0
  38. data/test/test_helper.rb +37 -0
  39. data/test/unit/capistrano_support_test.rb +147 -0
  40. data/test/unit/cron_test.rb +244 -0
  41. data/test/unit/job_test.rb +114 -0
  42. data/whenever.gemspec +27 -0
  43. metadata +167 -0
@@ -0,0 +1,153 @@
1
+ require 'chronic'
2
+
3
+ module Whenever
4
+ module Output
5
+ class Cron
6
+ KEYWORDS = [:reboot, :yearly, :annually, :monthly, :weekly, :daily, :midnight, :hourly]
7
+ REGEX = /^(@(#{KEYWORDS.join '|'})|((\*?[\d\/,\-]*)\s*){5})$/
8
+
9
+ attr_accessor :time, :task
10
+
11
+ def initialize(time = nil, task = nil, at = nil)
12
+ @at_given = at
13
+ @time = time
14
+ @task = task
15
+ @at = at.is_a?(String) ? (Chronic.parse(at) || 0) : (at || 0)
16
+ end
17
+
18
+ def self.enumerate(item, detect_cron = true)
19
+ if item and item.is_a?(String)
20
+ items =
21
+ if detect_cron && item =~ REGEX
22
+ [item]
23
+ else
24
+ item.split(',')
25
+ end
26
+ else
27
+ items = item
28
+ items = [items] unless items and items.respond_to?(:each)
29
+ end
30
+ items
31
+ end
32
+
33
+ def self.output(times, job)
34
+ enumerate(times).each do |time|
35
+ enumerate(job.at, false).each do |at|
36
+ yield new(time, job.output, at).output
37
+ end
38
+ end
39
+ end
40
+
41
+ def output
42
+ [time_in_cron_syntax, task].compact.join(' ').strip
43
+ end
44
+
45
+ def time_in_cron_syntax
46
+ @time = @time.to_i if @time.is_a?(Numeric) # Compatibility with `1.day` format using ruby 2.3 and activesupport
47
+ case @time
48
+ when REGEX then @time # raw cron syntax given
49
+ when Symbol then parse_symbol
50
+ when String then parse_as_string
51
+ else parse_time
52
+ end
53
+ end
54
+
55
+ protected
56
+ def day_given?
57
+ months = %w(jan feb mar apr may jun jul aug sep oct nov dec)
58
+ @at_given.is_a?(String) && months.any? { |m| @at_given.downcase.index(m) }
59
+ end
60
+
61
+ def parse_symbol
62
+ shortcut = case @time
63
+ when *KEYWORDS then "@#{@time}" # :reboot => '@reboot'
64
+ when :year then Whenever.seconds(12, :months)
65
+ when :day then Whenever.seconds(1, :day)
66
+ when :month then Whenever.seconds(1, :month)
67
+ when :week then Whenever.seconds(1, :week)
68
+ when :hour then Whenever.seconds(1, :hour)
69
+ end
70
+
71
+ if shortcut.is_a?(Numeric)
72
+ @time = shortcut
73
+ parse_time
74
+ elsif shortcut
75
+ if @at.is_a?(Time) || (@at.is_a?(Numeric) && @at > 0)
76
+ raise ArgumentError, "You cannot specify an ':at' when using the shortcuts for times."
77
+ else
78
+ return shortcut
79
+ end
80
+ else
81
+ parse_as_string
82
+ end
83
+ end
84
+
85
+ def parse_time
86
+ timing = Array.new(5, '*')
87
+ case @time
88
+ when Whenever.seconds(0, :seconds)...Whenever.seconds(1, :minute)
89
+ raise ArgumentError, "Time must be in minutes or higher"
90
+ when Whenever.seconds(1, :minute)...Whenever.seconds(1, :hour)
91
+ minute_frequency = @time / 60
92
+ timing[0] = comma_separated_timing(minute_frequency, 59, @at || 0)
93
+ when Whenever.seconds(1, :hour)...Whenever.seconds(1, :day)
94
+ hour_frequency = (@time / 60 / 60).round
95
+ timing[0] = @at.is_a?(Time) ? @at.min : @at
96
+ timing[1] = comma_separated_timing(hour_frequency, 23)
97
+ when Whenever.seconds(1, :day)...Whenever.seconds(1, :month)
98
+ day_frequency = (@time / 24 / 60 / 60).round
99
+ timing[0] = @at.is_a?(Time) ? @at.min : 0
100
+ timing[1] = @at.is_a?(Time) ? @at.hour : @at
101
+ timing[2] = comma_separated_timing(day_frequency, 31, 1)
102
+ when Whenever.seconds(1, :month)..Whenever.seconds(12, :months)
103
+ month_frequency = (@time / 30 / 24 / 60 / 60).round
104
+ timing[0] = @at.is_a?(Time) ? @at.min : 0
105
+ timing[1] = @at.is_a?(Time) ? @at.hour : 0
106
+ timing[2] = if @at.is_a?(Time)
107
+ day_given? ? @at.day : 1
108
+ else
109
+ @at.zero? ? 1 : @at
110
+ end
111
+ timing[3] = comma_separated_timing(month_frequency, 12, 1)
112
+ else
113
+ return parse_as_string
114
+ end
115
+ timing.join(' ')
116
+ end
117
+
118
+ def parse_as_string
119
+ return unless @time
120
+ string = @time.to_s
121
+
122
+ timing = Array.new(4, '*')
123
+ timing[0] = @at.is_a?(Time) ? @at.min : 0
124
+ timing[1] = @at.is_a?(Time) ? @at.hour : 0
125
+
126
+ return (timing << '1-5') * " " if string.downcase.index('weekday')
127
+ return (timing << '6,0') * " " if string.downcase.index('weekend')
128
+
129
+ %w(sun mon tue wed thu fri sat).each_with_index do |day, i|
130
+ return (timing << i) * " " if string.downcase.index(day)
131
+ end
132
+
133
+ raise ArgumentError, "Couldn't parse: #{@time}"
134
+ end
135
+
136
+ def comma_separated_timing(frequency, max, start = 0)
137
+ return start if frequency.nil? || frequency == "" || frequency.zero?
138
+ return '*' if frequency == 1
139
+ return frequency if frequency > (max * 0.5).ceil
140
+
141
+ original_start = start
142
+
143
+ start += frequency unless (max + 1).modulo(frequency).zero? || start > 0
144
+ output = (start..max).step(frequency).to_a
145
+
146
+ max_occurances = (max.to_f / (frequency.to_f)).round
147
+ max_occurances += 1 if original_start.zero?
148
+
149
+ output[0, max_occurances].join(',')
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,54 @@
1
+ require 'shellwords'
2
+
3
+ module Whenever
4
+ class Job
5
+ attr_reader :at, :roles
6
+
7
+ def initialize(options = {})
8
+ @options = options
9
+ @at = options.delete(:at)
10
+ @template = options.delete(:template)
11
+ @job_template = options.delete(:job_template) || ":job"
12
+ @roles = Array(options.delete(:roles))
13
+ @options[:output] = options.has_key?(:output) ? Whenever::Output::Redirection.new(options[:output]).to_s : ''
14
+ @options[:environment_variable] ||= "RAILS_ENV"
15
+ @options[:environment] ||= :production
16
+ @options[:path] = Shellwords.shellescape(@options[:path] || Whenever.path)
17
+ end
18
+
19
+ def output
20
+ job = process_template(@template, @options)
21
+ out = process_template(@job_template, @options.merge(:job => job))
22
+ out.gsub(/%/, '\%')
23
+ end
24
+
25
+ def has_role?(role)
26
+ roles.empty? || roles.include?(role)
27
+ end
28
+
29
+ protected
30
+
31
+ def process_template(template, options)
32
+ template.gsub(/:\w+/) do |key|
33
+ before_and_after = [$`[-1..-1], $'[0..0]]
34
+ option = options[key.sub(':', '').to_sym] || key
35
+
36
+ if before_and_after.all? { |c| c == "'" }
37
+ escape_single_quotes(option)
38
+ elsif before_and_after.all? { |c| c == '"' }
39
+ escape_double_quotes(option)
40
+ else
41
+ option
42
+ end
43
+ end.gsub(/\s+/m, " ").strip
44
+ end
45
+
46
+ def escape_single_quotes(str)
47
+ str.gsub(/'/) { "'\\''" }
48
+ end
49
+
50
+ def escape_double_quotes(str)
51
+ str.gsub(/"/) { '\"' }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,155 @@
1
+ module Whenever
2
+ class JobList
3
+ attr_reader :roles
4
+
5
+ def initialize(options)
6
+ @jobs, @env, @set_variables, @pre_set_variables = {}, {}, {}, {}
7
+
8
+ if options.is_a? String
9
+ options = { :string => options }
10
+ end
11
+
12
+ pre_set(options[:set])
13
+
14
+ @roles = options[:roles] || []
15
+
16
+ setup_file = File.expand_path('../setup.rb', __FILE__)
17
+ setup = File.read(setup_file)
18
+ schedule = if options[:string]
19
+ options[:string]
20
+ elsif options[:file]
21
+ File.read(options[:file])
22
+ end
23
+
24
+ instance_eval(setup, setup_file)
25
+ instance_eval(schedule, options[:file] || '<eval>')
26
+ end
27
+
28
+ def set(variable, value)
29
+ variable = variable.to_sym
30
+ return if @pre_set_variables[variable]
31
+
32
+ instance_variable_set("@#{variable}".to_sym, value)
33
+ self.class.send(:attr_reader, variable.to_sym)
34
+ @set_variables[variable] = value
35
+ end
36
+
37
+ def env(variable, value)
38
+ @env[variable.to_s] = value
39
+ end
40
+
41
+ def every(frequency, options = {})
42
+ @current_time_scope = frequency
43
+ @options = options
44
+ yield
45
+ end
46
+
47
+ def job_type(name, template)
48
+ singleton_class.class_eval do
49
+ define_method(name) do |task, *args|
50
+ options = { :task => task, :template => template }
51
+ options.merge!(args[0]) if args[0].is_a? Hash
52
+
53
+ # :cron_log was an old option for output redirection, it remains for backwards compatibility
54
+ options[:output] = (options[:cron_log] || @cron_log) if defined?(@cron_log) || options.has_key?(:cron_log)
55
+ # :output is the newer, more flexible option.
56
+ options[:output] = @output if defined?(@output) && !options.has_key?(:output)
57
+
58
+ @jobs[@current_time_scope] ||= []
59
+ @jobs[@current_time_scope] << Whenever::Job.new(@options.merge(@set_variables).merge(options))
60
+ end
61
+ end
62
+ end
63
+
64
+ def generate_cron_output
65
+ [environment_variables, cron_jobs].compact.join
66
+ end
67
+
68
+ private
69
+
70
+ #
71
+ # Takes a string like: "variable1=something&variable2=somethingelse"
72
+ # and breaks it into variable/value pairs. Used for setting variables at runtime from the command line.
73
+ # Only works for setting values as strings.
74
+ #
75
+ def pre_set(variable_string = nil)
76
+ return if variable_string.nil? || variable_string == ""
77
+
78
+ pairs = variable_string.split('&')
79
+ pairs.each do |pair|
80
+ next unless pair.index('=')
81
+ variable, value = *pair.split('=')
82
+ unless variable.nil? || variable == "" || value.nil? || value == ""
83
+ variable = variable.strip.to_sym
84
+ set(variable, value.strip)
85
+ @pre_set_variables[variable] = value
86
+ end
87
+ end
88
+ end
89
+
90
+ def environment_variables
91
+ return if @env.empty?
92
+
93
+ output = []
94
+ @env.each do |key, val|
95
+ output << "#{key}=#{val.nil? || val == "" ? '""' : val}\n"
96
+ end
97
+ output << "\n"
98
+
99
+ output.join
100
+ end
101
+
102
+ #
103
+ # Takes the standard cron output that Whenever generates and finds
104
+ # similar entries that can be combined. For example: If a job should run
105
+ # at 3:02am and 4:02am, instead of creating two jobs this method combines
106
+ # them into one that runs on the 2nd minute at the 3rd and 4th hour.
107
+ #
108
+ def combine(entries)
109
+ entries.map! { |entry| entry.split(/ +/, 6) }
110
+ 0.upto(4) do |f|
111
+ (entries.length-1).downto(1) do |i|
112
+ next if entries[i][f] == '*'
113
+ comparison = entries[i][0...f] + entries[i][f+1..-1]
114
+ (i-1).downto(0) do |j|
115
+ next if entries[j][f] == '*'
116
+ if comparison == entries[j][0...f] + entries[j][f+1..-1]
117
+ entries[j][f] += ',' + entries[i][f]
118
+ entries.delete_at(i)
119
+ break
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ entries.map { |entry| entry.join(' ') }
126
+ end
127
+
128
+ def cron_jobs
129
+ return if @jobs.empty?
130
+
131
+ shortcut_jobs = []
132
+ regular_jobs = []
133
+
134
+ output_all = roles.empty?
135
+ @jobs.each do |time, jobs|
136
+ jobs.each do |job|
137
+ next unless output_all || roles.any? do |r|
138
+ job.has_role?(r)
139
+ end
140
+ Whenever::Output::Cron.output(time, job) do |cron|
141
+ cron << "\n\n"
142
+
143
+ if cron[0,1] == "@"
144
+ shortcut_jobs << cron
145
+ else
146
+ regular_jobs << cron
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ shortcut_jobs.join + combine(regular_jobs).join
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,13 @@
1
+ class Numeric
2
+ def respond_to?(method, include_private = false)
3
+ super || Whenever::NumericSeconds.public_method_defined?(method)
4
+ end
5
+
6
+ def method_missing(method, *args, &block)
7
+ if Whenever::NumericSeconds.public_method_defined?(method)
8
+ Whenever::NumericSeconds.new(self).send(method)
9
+ else
10
+ super
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ module Whenever
2
+ class NumericSeconds
3
+ attr_reader :number
4
+
5
+ def self.seconds(number, units)
6
+ new(number).send(units)
7
+ end
8
+
9
+ def initialize(number)
10
+ @number = number.to_i
11
+ end
12
+
13
+ def seconds
14
+ number
15
+ end
16
+ alias :second :seconds
17
+
18
+ def minutes
19
+ number * 60
20
+ end
21
+ alias :minute :minutes
22
+
23
+ def hours
24
+ number * 3_600
25
+ end
26
+ alias :hour :hours
27
+
28
+ def days
29
+ number * 86_400
30
+ end
31
+ alias :day :days
32
+
33
+ def weeks
34
+ number * 604_800
35
+ end
36
+ alias :week :weeks
37
+
38
+ def months
39
+ number * 2_592_000
40
+ end
41
+ alias :month :months
42
+
43
+ def years
44
+ number * 31_557_600
45
+ end
46
+ alias :year :years
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ module Whenever
2
+ module OS
3
+ def self.solaris?
4
+ (/solaris/ =~ RUBY_PLATFORM)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,57 @@
1
+ module Whenever
2
+ module Output
3
+ class Redirection
4
+ def initialize(output)
5
+ @output = output
6
+ end
7
+
8
+ def to_s
9
+ return '' unless defined?(@output)
10
+ case @output
11
+ when String then redirect_from_string
12
+ when Hash then redirect_from_hash
13
+ when NilClass then ">> /dev/null 2>&1"
14
+ when Proc then @output.call
15
+ else ''
16
+ end
17
+ end
18
+
19
+ protected
20
+
21
+ def stdout
22
+ return unless @output.has_key?(:standard)
23
+ @output[:standard].nil? ? '/dev/null' : @output[:standard]
24
+ end
25
+
26
+ def stderr
27
+ return unless @output.has_key?(:error)
28
+ @output[:error].nil? ? '/dev/null' : @output[:error]
29
+ end
30
+
31
+ def redirect_from_hash
32
+ case
33
+ when stdout == '/dev/null' && stderr == '/dev/null'
34
+ "> /dev/null 2>&1"
35
+ when stdout && stderr == '/dev/null'
36
+ ">> #{stdout} 2> /dev/null"
37
+ when stdout && stderr
38
+ ">> #{stdout} 2>> #{stderr}"
39
+ when stderr == '/dev/null'
40
+ "2> /dev/null"
41
+ when stderr
42
+ "2>> #{stderr}"
43
+ when stdout == '/dev/null'
44
+ "> /dev/null"
45
+ when stdout
46
+ ">> #{stdout}"
47
+ else
48
+ ''
49
+ end
50
+ end
51
+
52
+ def redirect_from_string
53
+ ">> #{@output} 2>&1"
54
+ end
55
+ end
56
+ end
57
+ end