whenever-benlangfeld 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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