cronwatch 0.3.1

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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +268 -0
  4. data/lib/cronwatch/abort_signal.rb +45 -0
  5. data/lib/cronwatch/active_record.rb +11 -0
  6. data/lib/cronwatch/alerts/console.rb +22 -0
  7. data/lib/cronwatch/alerts/custom.rb +26 -0
  8. data/lib/cronwatch/alerts/discord.rb +52 -0
  9. data/lib/cronwatch/alerts/slack.rb +58 -0
  10. data/lib/cronwatch/alerts/webhook.rb +46 -0
  11. data/lib/cronwatch/client.rb +925 -0
  12. data/lib/cronwatch/cron_pattern.rb +277 -0
  13. data/lib/cronwatch/duration.rb +77 -0
  14. data/lib/cronwatch/environment.rb +29 -0
  15. data/lib/cronwatch/evaluate.rb +345 -0
  16. data/lib/cronwatch/flight.rb +42 -0
  17. data/lib/cronwatch/format.rb +89 -0
  18. data/lib/cronwatch/http.rb +52 -0
  19. data/lib/cronwatch/job.rb +144 -0
  20. data/lib/cronwatch/js.rb +188 -0
  21. data/lib/cronwatch/monitored.rb +259 -0
  22. data/lib/cronwatch/output.rb +199 -0
  23. data/lib/cronwatch/rails/active_job.rb +55 -0
  24. data/lib/cronwatch/rails/check_job.rb +32 -0
  25. data/lib/cronwatch/rails/railtie.rb +37 -0
  26. data/lib/cronwatch/rails/tasks.rb +12 -0
  27. data/lib/cronwatch/rails.rb +35 -0
  28. data/lib/cronwatch/schedule.rb +191 -0
  29. data/lib/cronwatch/scheduler.rb +763 -0
  30. data/lib/cronwatch/serialize.rb +51 -0
  31. data/lib/cronwatch/sidekiq.rb +129 -0
  32. data/lib/cronwatch/stats.rb +23 -0
  33. data/lib/cronwatch/stores/active_record.rb +397 -0
  34. data/lib/cronwatch/stores/memory.rb +163 -0
  35. data/lib/cronwatch/ticker.rb +59 -0
  36. data/lib/cronwatch/triage/anthropic.rb +134 -0
  37. data/lib/cronwatch/types.rb +341 -0
  38. data/lib/cronwatch/version.rb +6 -0
  39. data/lib/cronwatch/walker.rb +137 -0
  40. data/lib/cronwatch/web/app.rb +484 -0
  41. data/lib/cronwatch/web/html.rb +314 -0
  42. data/lib/cronwatch/web.rb +17 -0
  43. data/lib/cronwatch/zone.rb +72 -0
  44. data/lib/cronwatch.rb +96 -0
  45. data/lib/generators/cronwatch/install/install_generator.rb +176 -0
  46. metadata +104 -0
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fugit"
4
+ require_relative "zone"
5
+ require_relative "walker"
6
+
7
+ module Cronwatch
8
+ # A cron expression's fields, and the next time they match. What is
9
+ # accepted, and which times match, follow croner; Fugit parses the values.
10
+ class CronPattern
11
+ NICKNAMES = {
12
+ "@yearly" => "0 0 1 1 *", "@annually" => "0 0 1 1 *", "@monthly" => "0 0 1 * *", "@weekly" => "0 0 * * 0",
13
+ "@daily" => "0 0 * * *", "@midnight" => "0 0 * * *", "@hourly" => "0 * * * *",
14
+ }.freeze
15
+ SIZES = { second: 60, minute: 60, hour: 24, day: 31, month: 12, dayOfWeek: 7 }.freeze
16
+ # Croner's bits for "the nth weekday of the month"; 32 is the last one, 63 any.
17
+ NTH = [1, 2, 4, 8, 16].freeze
18
+ LAST_NTH = 32
19
+ ANY_NTH = 63
20
+
21
+ attr_reader :seconds, :minutes, :hours, :days, :months, :weekdays, :last_day_of_month, :star_dom, :star_dow,
22
+ :and_logic
23
+
24
+ def self.compile(text)
25
+ new(text)
26
+ end
27
+
28
+ def initialize(text)
29
+ fields, dom, dow = croner_fields(text)
30
+ cron = begin
31
+ Fugit::Cron.parse(fields.each_with_index.map { |f, i| i == 3 ? dom : (i == 5 ? dow : f) }.join(" "))
32
+ rescue StandardError
33
+ nil
34
+ end
35
+ # Croner accepts a few forms Fugit does not read (a range with #, a day of
36
+ # the month with L after it, a date no month has). Those are refused.
37
+ raise ArgumentError, "CronPattern: '#{text}' uses a form the Ruby port does not read" unless cron
38
+
39
+ @seconds = flags(cron.seconds, 60, 0)
40
+ @minutes = flags(cron.minutes, 60, 0)
41
+ @hours = flags(cron.hours, 24, 0)
42
+ @days = Array.new(31, false)
43
+ if cron.monthdays.nil?
44
+ @days.fill(true)
45
+ else
46
+ cron.monthdays.each { |d| @days[d - 1] = true if d.positive? }
47
+ end
48
+ @months = flags(cron.months, 12, 1)
49
+ @weekdays = Array.new(7, 0)
50
+ if cron.weekdays.nil?
51
+ @weekdays.fill(ANY_NTH)
52
+ else
53
+ cron.weekdays.each do |day, nth|
54
+ day = 0 if day == 7
55
+ @weekdays[day] =
56
+ if nth.nil? then ANY_NTH
57
+ elsif nth == -1 then @weekdays[day] | LAST_NTH
58
+ else @weekdays[day] | NTH[nth - 1]
59
+ end
60
+ end
61
+ end
62
+ freeze
63
+ end
64
+
65
+ # The first fire strictly after epoch milliseconds `from`, as epoch
66
+ # milliseconds, or nil when there is none before the year 3000.
67
+ def next_after(from, timezone)
68
+ wall = Zone.wall(from.div(1000), timezone)
69
+ date = Walker.new(self, wall)
70
+ date.second += 1
71
+ date.apply
72
+ found = date.search
73
+ found && Zone.to_utc(found, timezone) * 1000
74
+ end
75
+
76
+ private
77
+
78
+ def flags(values, size, base)
79
+ return Array.new(size, true) if values.nil?
80
+
81
+ out = Array.new(size, false)
82
+ values.each { |v| out[v - base] = true }
83
+ out
84
+ end
85
+
86
+ # Applies croner's reading of the text: nicknames, five fields padded with
87
+ # seconds, month and weekday names, L, ?, the + for AND, and the checks
88
+ # croner makes on every field, with its messages. Returns the six fields,
89
+ # and the day-of-month and day-of-week fields to hand Fugit.
90
+ def croner_fields(text)
91
+ pattern = text
92
+ pattern = JS.trim(NICKNAMES.fetch(JS.trim(pattern).downcase) { nickname_or_text(pattern) }) if pattern.include?("@")
93
+ parts = pattern.scan(JS::NOT_SPACE)
94
+ parts = [""] if parts.empty?
95
+ if parts.length < 5 || parts.length > 7
96
+ raise ArgumentError, "CronPattern: invalid configuration format ('#{pattern}'), exactly five, six, or seven space separated parts are required."
97
+ end
98
+ raise ArgumentError, "CronPattern: a seventh (year) field is not supported by the Ruby port" if parts.length == 7
99
+
100
+ parts.unshift("0") if parts.length == 5
101
+ @last_day_of_month = false
102
+ fugit_dom = parts[3]
103
+ if parts[3].upcase == "LW"
104
+ raise ArgumentError, "CronPattern: LW is not supported by the Ruby port"
105
+ elsif parts[3].upcase.include?("L")
106
+ parts[3] = parts[3].gsub(/L/i, "")
107
+ @last_day_of_month = true
108
+ end
109
+ @star_dom = parts[3] == "*"
110
+ parts[4] = month_names(parts[4]) if parts[4].length >= 3
111
+ parts[5] = day_names(parts[5]) if parts[5].length >= 3
112
+ @and_logic = false
113
+ if parts[5].start_with?("+")
114
+ @and_logic = true
115
+ parts[5] = parts[5][1..]
116
+ raise ArgumentError, "CronPattern: Day-of-week field cannot be empty after '+' modifier." if parts[5].empty?
117
+ end
118
+ @star_dow = parts[5] == "*"
119
+ if pattern.include?("?")
120
+ parts.map! { |p| p.tr("?", "*") }
121
+ fugit_dom = fugit_dom.tr("?", "*")
122
+ end
123
+ parts.each_with_index do |part, i|
124
+ illegal = i == 3 ? %r{[^/*0-9,\-WwLl]+} : (i == 5 ? %r{[^/*0-9,\-#Ll]+} : %r{[^/*0-9,\-]+})
125
+ raise ArgumentError, "CronPattern: configuration entry #{i} (#{part}) contains illegal characters." if illegal.match?(part)
126
+ end
127
+ raise ArgumentError, "CronPattern: W is not supported by the Ruby port" if parts[3].match?(/w/i)
128
+
129
+ check(:second, parts[0], 0)
130
+ check(:minute, parts[1], 0)
131
+ check(:hour, parts[2], 0)
132
+ check(:day, parts[3], -1)
133
+ check(:month, parts[4], -1)
134
+ check(:dayOfWeek, parts[5], 0)
135
+ # Croner writes the last Friday as 5L, Fugit as 5#-1.
136
+ fugit_dow = parts[5].gsub(/(\d+)L/i) { "#{Regexp.last_match(1)}#-1" }
137
+ fugit_dom = fugit_dom.gsub(/L/i, "L")
138
+ [parts, fugit_dom, fugit_dow]
139
+ end
140
+
141
+ def nickname_or_text(pattern)
142
+ raise ArgumentError, "CronPattern: @reboot is not supported in this environment. This is an event-based trigger that requires system startup detection." if JS.trim(pattern).downcase == "@reboot"
143
+
144
+ pattern
145
+ end
146
+
147
+ def month_names(text)
148
+ %w[jan feb mar apr may jun jul aug sep oct nov dec].each_with_index.reduce(text) do |out, (name, i)|
149
+ out.gsub(/#{name}/i, (i + 1).to_s)
150
+ end
151
+ end
152
+
153
+ def day_names(text)
154
+ text = text.gsub(/-sun/i, "-7")
155
+ %w[sun mon tue wed thu fri sat].each_with_index.reduce(text) { |out, (name, i)| out.gsub(/#{name}/i, i.to_s) }
156
+ end
157
+
158
+ # Croner's partToArray, keeping only its checks.
159
+ def check(type, text, offset)
160
+ if text == "" && !(type == :day && @last_day_of_month)
161
+ raise ArgumentError, "CronPattern: configuration entry #{type} (#{text}) is empty, check for trailing spaces."
162
+ end
163
+ return if text == "*"
164
+
165
+ items = text.split(",", -1)
166
+ if items.length > 1
167
+ items.each { |item| check(type, item, offset) }
168
+ elsif text.include?("-") && text.include?("/")
169
+ check_range_with_step(type, text, offset)
170
+ elsif text.include?("-")
171
+ check_range(type, text, offset)
172
+ elsif text.include?("/")
173
+ check_step(type, text, offset)
174
+ elsif text != ""
175
+ value, nth = extract_nth(text, type)
176
+ number = parse_int(value)
177
+ raise ArgumentError, "CronPattern: #{type} is not a number: '#{text}'" if number.nil?
178
+
179
+ check_value(type, number + offset, nth)
180
+ end
181
+ end
182
+
183
+ def check_range_with_step(type, text, offset)
184
+ value, nth = extract_nth(text, type)
185
+ match = %r{\A(\d+)-(\d+)/(\d+)\z}.match(value)
186
+ raise ArgumentError, "CronPattern: Syntax error, illegal range with stepping: '#{text}'" unless match
187
+
188
+ from = match[1].to_i + offset
189
+ to = match[2].to_i + offset
190
+ step = match[3].to_i
191
+ check_range_bounds(from, to, step, type, text)
192
+ from.step(to, step) { |v| check_value(type, v, nth) }
193
+ end
194
+
195
+ def check_range(type, text, offset)
196
+ value, nth = extract_nth(text, type)
197
+ bounds = value.split("-", -1)
198
+ raise ArgumentError, "CronPattern: Syntax error, illegal range: '#{text}'" unless bounds.length == 2
199
+
200
+ from = parse_int(bounds[0])
201
+ to = parse_int(bounds[1])
202
+ raise ArgumentError, "CronPattern: Syntax error, illegal lower range (NaN)" if from.nil?
203
+ raise ArgumentError, "CronPattern: Syntax error, illegal upper range (NaN)" if to.nil?
204
+
205
+ check_range_bounds(from + offset, to + offset, nil, type, text)
206
+ (from + offset).upto(to + offset) { |v| check_value(type, v, nth) }
207
+ end
208
+
209
+ def check_step(type, text, _offset)
210
+ value, nth = extract_nth(text, type)
211
+ parts = value.split("/", -1)
212
+ raise ArgumentError, "CronPattern: Syntax error, illegal stepping: '#{text}'" unless parts.length == 2
213
+ if parts[0] == ""
214
+ raise ArgumentError, "CronPattern: Syntax error, stepping with missing prefix ('#{text}') is not allowed. Use wildcard (*/step) or range (min-max/step) instead."
215
+ end
216
+ if parts[0] != "*"
217
+ raise ArgumentError, "CronPattern: Syntax error, stepping with numeric prefix ('#{text}') is not allowed. Use wildcard (*/step) or range (min-max/step) instead."
218
+ end
219
+
220
+ step = parse_int(parts[1])
221
+ raise ArgumentError, "CronPattern: Syntax error, illegal stepping: (NaN)" if step.nil?
222
+
223
+ check_range_bounds(0, SIZES[type] - 1, step, type, text)
224
+ 0.step(SIZES[type] - 1, step) { |v| check_value(type, v, nth) }
225
+ end
226
+
227
+ def check_range_bounds(from, to, step, type, text)
228
+ raise ArgumentError, "CronPattern: From value is larger than to value: '#{text}'" if from > to
229
+ return if step.nil?
230
+ raise ArgumentError, "CronPattern: Syntax error, illegal stepping: 0" if step.zero?
231
+ if step > SIZES[type]
232
+ raise ArgumentError, "CronPattern: Syntax error, steps cannot be greater than maximum value of part (#{SIZES[type]})"
233
+ end
234
+ end
235
+
236
+ def extract_nth(text, type)
237
+ if text.include?("#")
238
+ raise ArgumentError, "CronPattern: nth (#) only allowed in day-of-week field" unless type == :dayOfWeek
239
+
240
+ pieces = text.split("#", -1)
241
+ [pieces[0], pieces[1]]
242
+ elsif text.upcase.end_with?("L")
243
+ unless type == :dayOfWeek
244
+ raise ArgumentError, "CronPattern: L modifier only allowed in day-of-week field (use L alone for day-of-month)"
245
+ end
246
+
247
+ [text[0...-1], "L"]
248
+ else
249
+ [text, nil]
250
+ end
251
+ end
252
+
253
+ def check_value(type, value, nth)
254
+ if type == :dayOfWeek
255
+ value = 0 if value == 7
256
+ raise ArgumentError, "CronPattern: Invalid value for dayOfWeek: #{value}" if value.negative? || value > 6
257
+ # An empty nth ("1#") falls back to any, as `"" || 63` does in croner.
258
+ return if nth.nil? || nth == "" || nth.upcase == "L"
259
+
260
+ n = nth.match?(/\A\d+\z/) ? nth.to_i : nil
261
+ return if n && n < 6 && n.positive?
262
+
263
+ raise ArgumentError, "CronPattern: nth weekday out of range, should be 1-5 or L. Value: #{nth}, Type: string"
264
+ end
265
+ limit = SIZES[type]
266
+ return if value >= 0 && value < limit
267
+
268
+ raise ArgumentError, "CronPattern: Invalid value for #{type}: #{value}"
269
+ end
270
+
271
+ # JavaScript's parseInt, for the characters a field may hold: nil for NaN.
272
+ def parse_int(text)
273
+ match = /\A[+-]?\d+/.match(text)
274
+ match && match[0].to_i
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ # "15m", "1h30m", "90s", "2d", or a number of milliseconds.
5
+ module Duration
6
+ UNIT_MS = { "ms" => 1, "s" => 1000, "m" => 60_000, "h" => 3_600_000, "d" => 86_400_000, "w" => 604_800_000 }.freeze
7
+ PART = Regexp.new("(\\d+(?:\\.\\d+)?)[#{JS::WHITESPACE}]*(ms|s|m|h|d|w)")
8
+
9
+ module_function
10
+
11
+ # An ActiveSupport::Duration (15.minutes), when ActiveSupport is loaded.
12
+ def active_support?(value)
13
+ defined?(::ActiveSupport::Duration) && value.instance_of?(::ActiveSupport::Duration)
14
+ end
15
+
16
+ # "15m" -> 900000. Accepts a plain number of milliseconds, and compound
17
+ # strings such as "1h30m". Whitespace between parts is fine. An
18
+ # ActiveSupport::Duration (15.minutes) is read as what it says.
19
+ def parse(value, label = "duration")
20
+ if active_support?(value)
21
+ return JS.round(value.to_f * 1000)
22
+ end
23
+ if value.is_a?(Numeric)
24
+ raise ArgumentError, "#{label} must be a non-negative number of milliseconds" if !JS.finite?(value) || value.negative?
25
+
26
+ return value
27
+ end
28
+ unless value.is_a?(String)
29
+ raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\""
30
+ end
31
+
32
+ text = JS.trim(value).downcase
33
+ raise ArgumentError, "#{label} is empty" if text.empty?
34
+
35
+ total = 0
36
+ consumed = +""
37
+ text.scan(PART) do
38
+ match = Regexp.last_match
39
+ total += Float(match[1]) * UNIT_MS.fetch(match[2])
40
+ consumed << match[0]
41
+ end
42
+ if consumed.gsub(JS::SPACES, "") != text.gsub(JS::SPACES, "")
43
+ raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\""
44
+ end
45
+
46
+ JS.round(total)
47
+ end
48
+
49
+ # 90000 -> "1m 30s". For messages, not for parsing back.
50
+ def format(ms)
51
+ return "?" unless JS.finite?(ms)
52
+ return "#{JS.round(ms)}ms" if ms < 1000
53
+
54
+ parts = []
55
+ rest = JS.round(ms / 1000.0)
56
+ [["d", 86_400], ["h", 3_600], ["m", 60], ["s", 1]].each do |unit, size|
57
+ if rest >= size
58
+ n = rest.div(size)
59
+ rest -= n * size
60
+ parts << "#{n}#{unit}"
61
+ end
62
+ break if parts.length == 2
63
+ end
64
+ parts.empty? ? "0s" : parts.join(" ")
65
+ end
66
+
67
+ # "5m ago", "in 2h". Relative to `now`.
68
+ def relative(at, now)
69
+ diff = at - now
70
+ abs = diff.abs
71
+ return "now" if abs < 5_000
72
+
73
+ text = format(abs)
74
+ diff.negative? ? "#{text} ago" : "in #{text}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ # The app's environment, read one way everywhere: Rails.env when Rails is
5
+ # loaded, otherwise RAILS_ENV, then RACK_ENV. The SDK reads NODE_ENV.
6
+ module Environment
7
+ DEVELOPMENT = %w[development test].freeze
8
+
9
+ module_function
10
+
11
+ # "production", "development", ... or nil when nothing names one.
12
+ def name
13
+ if defined?(::Rails) && ::Rails.respond_to?(:env)
14
+ env = ::Rails.env.to_s
15
+ return env unless env.empty?
16
+ end
17
+ [ENV.fetch("RAILS_ENV", nil), ENV.fetch("RACK_ENV", nil)].find { |value| value && !value.empty? }
18
+ end
19
+
20
+ # Development or test: Cronwatch::Web without a token serves only then.
21
+ def development?
22
+ DEVELOPMENT.include?(name)
23
+ end
24
+
25
+ def production?
26
+ name == "production"
27
+ end
28
+ end
29
+ end