cronwatch 0.13.1 → 0.14.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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/cronwatch/alerts/email.rb +1 -1
- data/lib/cronwatch/alerts/slack.rb +10 -2
- data/lib/cronwatch/alerts/webhook.rb +35 -9
- data/lib/cronwatch/client.rb +76 -38
- data/lib/cronwatch/environment.rb +1 -1
- data/lib/cronwatch/evaluate.rb +174 -31
- data/lib/cronwatch/http.rb +10 -9
- data/lib/cronwatch/job.rb +20 -8
- data/lib/cronwatch/schedule.rb +166 -51
- data/lib/cronwatch/triage/anthropic.rb +3 -3
- data/lib/cronwatch/types.rb +21 -0
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +50 -6
- data/lib/cronwatch/web/timeline.rb +14 -10
- data/lib/cronwatch/zone.rb +3 -0
- metadata +1 -1
data/lib/cronwatch/schedule.rb
CHANGED
|
@@ -103,12 +103,29 @@ module Cronwatch
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
# The first fire strictly after `from`, or nil when the cron never fires
|
|
106
|
-
# again.
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
106
|
+
# again. On the night clocks spring forward every slot the pattern names
|
|
107
|
+
# still fires: one whose local time the jump skips is moved forward by the
|
|
108
|
+
# gap's length (see spring_gap and gap_fire_after), so the fires stay
|
|
109
|
+
# strictly ascending whichever instant they are counted from. Ported from
|
|
110
|
+
# fireAfter in schedule.ts.
|
|
111
111
|
def fire_after(parsed, from)
|
|
112
|
+
found = crone_fire_after(parsed, from)
|
|
113
|
+
start = count_from(from)
|
|
114
|
+
return found if start.nil?
|
|
115
|
+
|
|
116
|
+
moved = gap_fire_after(parsed, start)
|
|
117
|
+
!moved.nil? && (found.nil? || moved < found) ? moved : found
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The first fire strictly after `from` as croner counts it, or nil when
|
|
121
|
+
# the cron never fires again. The walker works on the wall clock and
|
|
122
|
+
# Zone.to_utc takes the earlier of a repeated time, so, like croner, asked
|
|
123
|
+
# from inside the hour that repeats when clocks go back it can answer with
|
|
124
|
+
# times in the past. Its answers are filtered, and a stretch of nothing
|
|
125
|
+
# but past times is stepped over an hour at a time. Asked from inside a
|
|
126
|
+
# spring-forward gap it skips the slots moved out of it, which fire_after
|
|
127
|
+
# adds back. Ported from croneFireAfter in schedule.ts.
|
|
128
|
+
def crone_fire_after(parsed, from)
|
|
112
129
|
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
113
130
|
return nil if parsed.never
|
|
114
131
|
|
|
@@ -128,10 +145,79 @@ module Cronwatch
|
|
|
128
145
|
nil
|
|
129
146
|
end
|
|
130
147
|
|
|
148
|
+
# A spring-forward jump: `jump_at`, the first instant on the later offset;
|
|
149
|
+
# `gap_ms`, the later offset less the earlier one; `before_ms`, the earlier
|
|
150
|
+
# offset. All in milliseconds.
|
|
151
|
+
Gap = Struct.new(:jump_at, :gap_ms, :before_ms)
|
|
152
|
+
|
|
153
|
+
# How far back a spring-forward jump is looked for: more than any gap a zone has had.
|
|
154
|
+
JUMP_LOOKBACK_MS = 3 * 3_600_000
|
|
155
|
+
|
|
156
|
+
# The window a spring-forward jump moves skipped slots into, when `at`
|
|
157
|
+
# lies in one: from the jump for the gap's length (30 minutes on Lord Howe
|
|
158
|
+
# Island, an hour in most zones). Nil when `at` is not within a gap's
|
|
159
|
+
# length after a jump in the three hours before it.
|
|
160
|
+
def spring_gap(timezone, at)
|
|
161
|
+
jump = spring_jump(timezone, at)
|
|
162
|
+
jump && at < jump.jump_at + jump.gap_ms ? jump : nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The spring-forward jump in the three hours up to `at`, found to the
|
|
166
|
+
# minute: the first minute on the later offset. Nil when the offset at
|
|
167
|
+
# `at` is not ahead of the offset three hours before.
|
|
168
|
+
def spring_jump(timezone, at)
|
|
169
|
+
# Every zone kept its local mean time, with no clock change, in the year 1.
|
|
170
|
+
return nil if at - JUMP_LOOKBACK_MS < Duration::FIRST_DATE_MS || at >= Duration::LAST_DATE_MS
|
|
171
|
+
|
|
172
|
+
after = utc_offset(at, timezone)
|
|
173
|
+
before = utc_offset(at - JUMP_LOOKBACK_MS, timezone)
|
|
174
|
+
return nil if after <= before
|
|
175
|
+
|
|
176
|
+
lo = at - JUMP_LOOKBACK_MS
|
|
177
|
+
hi = at
|
|
178
|
+
while hi - lo > 60_000
|
|
179
|
+
mid = lo + (hi - lo).div(2)
|
|
180
|
+
if utc_offset(mid, timezone) == after then hi = mid
|
|
181
|
+
else lo = mid
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
Gap.new(hi.div(60_000) * 60_000, after - before, before)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The first slot the pattern names strictly after `at`, the pattern read
|
|
188
|
+
# in UTC, which stands for wall-clock time in gap_fire_after.
|
|
189
|
+
def wall_fire_after(parsed, at)
|
|
190
|
+
next_runs(parsed, 1, at, "UTC").first
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The first slot moved out of a spring-forward gap strictly after
|
|
194
|
+
# `start`, when `start` lies in that gap's window, else nil. A slot whose
|
|
195
|
+
# local time the jump skips fires at that local time read on the earlier
|
|
196
|
+
# offset, which is the same as moving it forward by the gap's length:
|
|
197
|
+
# 01:20 on the night London skips 01:00 to 02:00 fires at 02:20 BST
|
|
198
|
+
# (01:20Z). The walker moves them the same way when asked from before the
|
|
199
|
+
# jump, as croner does, but not from inside it.
|
|
200
|
+
def gap_fire_after(parsed, start)
|
|
201
|
+
return nil if parsed.never
|
|
202
|
+
|
|
203
|
+
gap = spring_gap(parsed.timezone, start)
|
|
204
|
+
return nil unless gap
|
|
205
|
+
|
|
206
|
+
# Wall-clock time as if it were UTC: the slots the jump skips run from
|
|
207
|
+
# jump_at + before_ms for gap_ms.
|
|
208
|
+
slot = wall_fire_after(parsed, start + gap.before_ms)
|
|
209
|
+
return nil if slot.nil? || slot >= gap.jump_at + gap.before_ms + gap.gap_ms
|
|
210
|
+
|
|
211
|
+
slot - gap.before_ms
|
|
212
|
+
end
|
|
213
|
+
|
|
131
214
|
# Every fire of a cron strictly after `from` and at or before `to`,
|
|
132
|
-
# ascending, or nil when there are more than `limit
|
|
133
|
-
#
|
|
134
|
-
# any that do not move forward
|
|
215
|
+
# ascending, or nil when there are more than `limit`: the fires next_fire
|
|
216
|
+
# gives one after another. Walks the fires in batches, as firesBetween in
|
|
217
|
+
# schedule.ts asks croner for them, and drops any that do not move forward
|
|
218
|
+
# (the hour that repeats when clocks go back, see crone_fire_after).
|
|
219
|
+
# Around a spring-forward gap, where a batch can skip a moved slot or give
|
|
220
|
+
# one out of order, it counts with fire_after instead.
|
|
135
221
|
def fires_between(parsed, from, to, limit)
|
|
136
222
|
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
137
223
|
return [] if parsed.never
|
|
@@ -143,17 +229,46 @@ module Cronwatch
|
|
|
143
229
|
probe = start
|
|
144
230
|
last = start
|
|
145
231
|
1000.times do
|
|
232
|
+
if spring_gap(parsed.timezone, last)
|
|
233
|
+
# Counting from inside a gap's window: the walker would skip its moved slots.
|
|
234
|
+
t = fire_after(parsed, last)
|
|
235
|
+
return out if t.nil? || t > to
|
|
236
|
+
|
|
237
|
+
out << t
|
|
238
|
+
last = probe = t
|
|
239
|
+
return nil if out.length > limit
|
|
240
|
+
|
|
241
|
+
next
|
|
242
|
+
end
|
|
146
243
|
batch = next_runs(parsed, [limit + 1 - out.length, 24].min, probe)
|
|
147
244
|
return out if batch.empty?
|
|
148
245
|
|
|
246
|
+
stepped = false
|
|
149
247
|
batch.each do |t|
|
|
150
248
|
next if t <= last
|
|
249
|
+
|
|
250
|
+
if spring_gap(parsed.timezone, t)
|
|
251
|
+
# The batch reached a gap's window, where its order is the
|
|
252
|
+
# walker's: the next fire is counted from the last one, and the
|
|
253
|
+
# rest with it.
|
|
254
|
+
following = fire_after(parsed, last)
|
|
255
|
+
return out if following.nil? || following > to
|
|
256
|
+
|
|
257
|
+
out << following
|
|
258
|
+
last = probe = following
|
|
259
|
+
return nil if out.length > limit
|
|
260
|
+
|
|
261
|
+
stepped = true
|
|
262
|
+
break
|
|
263
|
+
end
|
|
151
264
|
return out if t > to
|
|
152
265
|
|
|
153
266
|
out << t
|
|
154
267
|
last = t
|
|
155
268
|
return nil if out.length > limit
|
|
156
269
|
end
|
|
270
|
+
next if stepped
|
|
271
|
+
|
|
157
272
|
finish = batch.last
|
|
158
273
|
probe = finish > probe ? finish : probe + 3_600_000
|
|
159
274
|
end
|
|
@@ -177,7 +292,7 @@ module Cronwatch
|
|
|
177
292
|
# moved back, as runsAfter in schedule.ts asks croner: a time before 400
|
|
178
293
|
# goes forward, into the same local mean time every zone kept then, and
|
|
179
294
|
# one from 2800 goes back, to where the zone's present rules already hold.
|
|
180
|
-
def next_runs(parsed, count, from)
|
|
295
|
+
def next_runs(parsed, count, from, timezone = parsed.timezone)
|
|
181
296
|
from = count_from(from)
|
|
182
297
|
return [] if from.nil?
|
|
183
298
|
|
|
@@ -189,7 +304,7 @@ module Cronwatch
|
|
|
189
304
|
runs = []
|
|
190
305
|
at = from + shift
|
|
191
306
|
count.times do
|
|
192
|
-
at = parsed.pattern.next_after(at,
|
|
307
|
+
at = parsed.pattern.next_after(at, timezone)
|
|
193
308
|
break if at.nil? || at - shift > Duration::LAST_DATE_MS
|
|
194
309
|
|
|
195
310
|
runs << (at - shift)
|
|
@@ -197,25 +312,38 @@ module Cronwatch
|
|
|
197
312
|
runs
|
|
198
313
|
end
|
|
199
314
|
|
|
200
|
-
# The next time the schedule fires strictly after `from`. For an interval,
|
|
315
|
+
# The next time the schedule fires strictly after `from`. For an interval,
|
|
316
|
+
# counted from the last run's start when there is one (see interval_due
|
|
317
|
+
# for a run that outlasted its period).
|
|
201
318
|
def next_fire(parsed, from, last_run_at)
|
|
202
319
|
return (last_run_at || from) + parsed.every_ms if parsed.interval?
|
|
203
320
|
|
|
204
321
|
fire_after(parsed, from)
|
|
205
322
|
end
|
|
206
323
|
|
|
324
|
+
# When an interval of `every_ms` is next due after a run: a period after
|
|
325
|
+
# it started, or, when it finished (`finished_at` not nil) later than
|
|
326
|
+
# that, a period after it finished. A run that outlasts its period is not
|
|
327
|
+
# followed by a missed alert the moment it ends.
|
|
328
|
+
def interval_due(every_ms, started_at, finished_at)
|
|
329
|
+
due = started_at + every_ms
|
|
330
|
+
!finished_at.nil? && finished_at > due ? finished_at + every_ms : due
|
|
331
|
+
end
|
|
332
|
+
|
|
207
333
|
# When the schedule next wants a run, given the last one. For a cron that is
|
|
208
334
|
# the first fire the last run does not already cover; with no run yet, the
|
|
209
335
|
# first fire at or after registration. For an interval it is the last run's
|
|
210
|
-
# start (or registration) plus the interval
|
|
211
|
-
#
|
|
336
|
+
# start (or registration) plus the interval, or its finish plus the
|
|
337
|
+
# interval when it finished (`last_finished_at`, nil while it runs) later
|
|
338
|
+
# than that (interval_due). Nil for a cron that never fires again.
|
|
212
339
|
#
|
|
213
340
|
# Counting forward from the last run, rather than back from now, is what
|
|
214
341
|
# lets a job whose period is shorter than its grace be missed at all, and it
|
|
215
342
|
# works for a cron that fires once a year or less.
|
|
216
|
-
def expectation(parsed, last_run_at, registered_at, grace_ms)
|
|
343
|
+
def expectation(parsed, last_run_at, registered_at, grace_ms, last_finished_at = nil)
|
|
217
344
|
due_at =
|
|
218
|
-
if parsed.interval?
|
|
345
|
+
if parsed.interval?
|
|
346
|
+
last_run_at.nil? ? registered_at + parsed.every_ms : interval_due(parsed.every_ms, last_run_at, last_finished_at)
|
|
219
347
|
elsif last_run_at.nil? then fire_after(parsed, registered_at - 1)
|
|
220
348
|
else due_after_run(parsed, last_run_at)
|
|
221
349
|
end
|
|
@@ -227,12 +355,17 @@ module Cronwatch
|
|
|
227
355
|
# year 1 is due; after 9999 there is none (see count_from).
|
|
228
356
|
def due_after_run(parsed, started_at)
|
|
229
357
|
# A fire at or before the start is covered by the run itself.
|
|
230
|
-
|
|
231
|
-
|
|
358
|
+
due = fire_after(parsed, started_at)
|
|
359
|
+
# Bounded: a gap holds at most a few hours of moved slots.
|
|
360
|
+
100_000.times do
|
|
361
|
+
return nil if due.nil?
|
|
362
|
+
|
|
363
|
+
following = fire_after(parsed, due)
|
|
364
|
+
return due unless run_covers?(started_at, due, following) || in_spring_forward_gap?(parsed, started_at, due)
|
|
232
365
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
366
|
+
due = following
|
|
367
|
+
end
|
|
368
|
+
due
|
|
236
369
|
end
|
|
237
370
|
|
|
238
371
|
# Whether a run starting at `started_at` covers the fire at `due_at`. A minute
|
|
@@ -244,39 +377,21 @@ module Cronwatch
|
|
|
244
377
|
started_at >= due_at - slack
|
|
245
378
|
end
|
|
246
379
|
|
|
247
|
-
# On the night clocks spring forward, a
|
|
248
|
-
# (02:30 when 02:00 jumps to 03:00)
|
|
249
|
-
#
|
|
250
|
-
# A run that starts at or after the jump
|
|
251
|
-
#
|
|
252
|
-
# so neither scheduler's run is reported as missed.
|
|
253
|
-
#
|
|
254
|
-
#
|
|
380
|
+
# On the night clocks spring forward, a slot whose local time does not
|
|
381
|
+
# exist (02:30 when 02:00 jumps to 03:00) fires moved forward by the gap
|
|
382
|
+
# (03:30; see gap_fire_after), while vixie cron runs every skipped job
|
|
383
|
+
# once, at the jump itself (03:00). A run that starts at or after the jump
|
|
384
|
+
# (less a minute of slack), and before such a moved fire, is taken to
|
|
385
|
+
# cover it, so neither scheduler's run is reported as missed. Only a moved
|
|
386
|
+
# slot that is not also a slot of its own on the later offset counts: a
|
|
387
|
+
# cron that fires every 10 minutes moves 02:10 onto its own 03:10, which
|
|
388
|
+
# was not skipped.
|
|
255
389
|
def in_spring_forward_gap?(parsed, started_at, fire_at)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return false if fire_at - lookback < Duration::FIRST_DATE_MS
|
|
259
|
-
|
|
260
|
-
after = utc_offset(fire_at, parsed.timezone)
|
|
261
|
-
before = utc_offset(fire_at - lookback, parsed.timezone)
|
|
262
|
-
gap = after - before
|
|
263
|
-
return false if gap <= 0
|
|
264
|
-
|
|
265
|
-
# Find the jump: the first minute in the window with the later offset.
|
|
266
|
-
lo = fire_at - lookback
|
|
267
|
-
hi = fire_at
|
|
268
|
-
while hi - lo > 60_000
|
|
269
|
-
mid = lo + (hi - lo).div(2)
|
|
270
|
-
if utc_offset(mid, parsed.timezone) == after then hi = mid
|
|
271
|
-
else lo = mid
|
|
272
|
-
end
|
|
273
|
-
end
|
|
274
|
-
jump_at = hi.div(60_000) * 60_000
|
|
275
|
-
return false if fire_at - jump_at >= gap || started_at < jump_at - EARLY_SLACK_MS || started_at >= fire_at
|
|
390
|
+
gap = spring_gap(parsed.timezone, fire_at)
|
|
391
|
+
return false if gap.nil? || started_at < gap.jump_at - EARLY_SLACK_MS || started_at >= fire_at
|
|
276
392
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
fire_after(parsed, jump_at - 1) == fire_at
|
|
393
|
+
names = ->(at) { wall_fire_after(parsed, at - 1) == at }
|
|
394
|
+
names.call(fire_at + gap.before_ms) && !names.call(fire_at + gap.before_ms + gap.gap_ms)
|
|
280
395
|
end
|
|
281
396
|
|
|
282
397
|
# Milliseconds the zone's wall clock is ahead of UTC at `at`.
|
|
@@ -111,8 +111,8 @@ module Cronwatch
|
|
|
111
111
|
lines << "Job definition: #{JS.json(definition.respond_to?(:to_h) ? definition.to_h : definition)}"
|
|
112
112
|
if run
|
|
113
113
|
lines << ""
|
|
114
|
-
lines << "Triggering run: status #{run.status}, started #{stamp(run.started_at)}, duration #{duration(run)}, trigger #{run.trigger}"
|
|
115
|
-
lines << "Metrics: #{JS.json(run.metrics)}" if run.metrics && !run.metrics.empty?
|
|
114
|
+
lines << "Triggering run: status #{run.status}, started #{stamp(run.started_at)}, duration #{duration(run)}, trigger #{data(run.trigger.to_s)}"
|
|
115
|
+
lines << "Metrics: #{data(JS.json(run.metrics))}" if run.metrics && !run.metrics.empty?
|
|
116
116
|
lines << "Error:\n#{data(JS.head16(run.error, 3000))}" if present?(run.error)
|
|
117
117
|
lines << "Output (tail):\n#{data(JS.tail16(run.output, 3000))}" if present?(run.output)
|
|
118
118
|
end
|
|
@@ -122,7 +122,7 @@ module Cronwatch
|
|
|
122
122
|
lines << "Earlier runs, newest first:"
|
|
123
123
|
earlier.each do |r|
|
|
124
124
|
error = present?(r.error) ? ", error: #{data(JS.head16(r.error.split("\n", -1).first.to_s, 160))}" : ""
|
|
125
|
-
metrics = r.metrics && !r.metrics.empty? ? ", metrics #{JS.json(r.metrics)}" : ""
|
|
125
|
+
metrics = r.metrics && !r.metrics.empty? ? ", metrics #{data(JS.json(r.metrics))}" : ""
|
|
126
126
|
lines << "- #{r.status}, #{stamp(r.started_at)}, #{duration(r)}#{error}#{metrics}"
|
|
127
127
|
end
|
|
128
128
|
end
|
data/lib/cronwatch/types.rb
CHANGED
|
@@ -268,6 +268,27 @@ module Cronwatch
|
|
|
268
268
|
@triage_tried = true
|
|
269
269
|
end
|
|
270
270
|
|
|
271
|
+
# The keys of the channels that still owe a held alert (the SDK's
|
|
272
|
+
# HeldAlert.owed; see Evaluate.channel_keys), as stored, or nil when it
|
|
273
|
+
# has none, so every configured channel owes it. Kept with the fields
|
|
274
|
+
# this version does not otherwise read, so it is written after them, as
|
|
275
|
+
# the SDK's spread of an alert writes it.
|
|
276
|
+
def owed = @extra&.fetch("owed", nil)
|
|
277
|
+
|
|
278
|
+
# Whether the alert carries `owed` at all, whatever it holds.
|
|
279
|
+
def owed? = @extra&.key?("owed") || false
|
|
280
|
+
|
|
281
|
+
# A copy with `owed` set to `keys`, or without it when `keys` is nil.
|
|
282
|
+
def with_owed(keys)
|
|
283
|
+
extra = (@extra || {}).dup
|
|
284
|
+
if keys.nil? then extra.delete("owed")
|
|
285
|
+
else extra["owed"] = keys
|
|
286
|
+
end
|
|
287
|
+
copy = dup
|
|
288
|
+
copy.instance_variable_set(:@extra, extra.empty? ? nil : extra)
|
|
289
|
+
copy
|
|
290
|
+
end
|
|
291
|
+
|
|
271
292
|
# Read leniently, since a queued alert a foreign writer left malformed
|
|
272
293
|
# must affect only its own job: a field of the wrong type is kept as it
|
|
273
294
|
# came (a `type` or `run` that is not what it should be, `details` that
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -209,6 +209,9 @@ module Cronwatch
|
|
|
209
209
|
begin
|
|
210
210
|
request = Request.new(env)
|
|
211
211
|
path = strip_base(request.pathname, base)
|
|
212
|
+
# Not under the base at all: nothing here answers it, signed in or not.
|
|
213
|
+
return html(HTML.message_page("Not found", request.pathname, base), 404) if path.nil?
|
|
214
|
+
|
|
212
215
|
wants_html = !path.start_with?("/api")
|
|
213
216
|
serve(request, path, wants_html, base)
|
|
214
217
|
rescue StandardError => e
|
|
@@ -261,6 +264,7 @@ module Cronwatch
|
|
|
261
264
|
# the URL and every access log. Cross-site posts were refused above.
|
|
262
265
|
if @token && method == "POST" && path == "/signin"
|
|
263
266
|
body = read_body(request)
|
|
267
|
+
return html(HTML.message_page("Bad request", "Malformed request body", base), 400) if body.equal?(BAD_BODY)
|
|
264
268
|
# A body too large is no token at all.
|
|
265
269
|
sent = body.equal?(TOO_LARGE) ? nil : body["token"]
|
|
266
270
|
return sign_in_page(base) if sent.nil? || !HTTP.constant_time_equal?(sent, @token)
|
|
@@ -338,6 +342,7 @@ module Cronwatch
|
|
|
338
342
|
if action == "silence"
|
|
339
343
|
body = read_body(request)
|
|
340
344
|
return html(HTML.message_page("Not silenced", "The request was too large.", base), 413) if body.equal?(TOO_LARGE)
|
|
345
|
+
return html(HTML.message_page("Not silenced", "Malformed request body", base), 400) if body.equal?(BAD_BODY)
|
|
341
346
|
|
|
342
347
|
begin
|
|
343
348
|
duration = silence_duration(body["for"])
|
|
@@ -385,6 +390,7 @@ module Cronwatch
|
|
|
385
390
|
if rest[2] == "silence"
|
|
386
391
|
body = read_body(request)
|
|
387
392
|
return api({ ok: false, error: "Request body too large" }, 413) if body.equal?(TOO_LARGE)
|
|
393
|
+
return api({ ok: false, error: "Malformed request body" }, 400) if body.equal?(BAD_BODY)
|
|
388
394
|
|
|
389
395
|
begin
|
|
390
396
|
duration = silence_duration(body.key?("for") ? body["for"] : request.query("for"))
|
|
@@ -481,8 +487,15 @@ module Cronwatch
|
|
|
481
487
|
Digest::SHA256.hexdigest("cronwatch-cookie:#{token}")
|
|
482
488
|
end
|
|
483
489
|
|
|
490
|
+
# The path below the base, or nil when the request is not under it. The
|
|
491
|
+
# pathname, its dot segments already resolved (Request#pathname), must be
|
|
492
|
+
# the base or start with the base and a "/": "/cronwatchapi/jobs" is not
|
|
493
|
+
# under "/cronwatch", and neither is "/cronwatch/../api/jobs" (read as
|
|
494
|
+
# "/api/jobs"). An empty base, for an app mounted at the root, takes every path.
|
|
484
495
|
def strip_base(pathname, base)
|
|
485
|
-
|
|
496
|
+
return nil unless base.empty? || pathname == base || pathname.start_with?("#{base}/")
|
|
497
|
+
|
|
498
|
+
path = pathname[base.length..]
|
|
486
499
|
path = "/" if path.empty?
|
|
487
500
|
path = path[0...-1] if path.length > 1 && path.end_with?("/")
|
|
488
501
|
path
|
|
@@ -520,7 +533,8 @@ module Cronwatch
|
|
|
520
533
|
|
|
521
534
|
# A request body past Request::MAX_BODY, which read_body answers with.
|
|
522
535
|
TOO_LARGE = Object.new.freeze
|
|
523
|
-
|
|
536
|
+
BAD_BODY = Object.new.freeze
|
|
537
|
+
private_constant :TOO_LARGE, :BAD_BODY
|
|
524
538
|
|
|
525
539
|
# The form fields or JSON object of a request, each value as String(value)
|
|
526
540
|
# gives it in JavaScript, or TOO_LARGE for a body past Request::MAX_BODY.
|
|
@@ -536,13 +550,13 @@ module Cronwatch
|
|
|
536
550
|
if json
|
|
537
551
|
data = JSON.parse(Output.utf8(request.body).delete_prefix(""))
|
|
538
552
|
return data.to_h { |k, v| [k.to_s, js_string(v)] } if data.is_a?(Hash)
|
|
539
|
-
return
|
|
553
|
+
return BAD_BODY
|
|
540
554
|
else
|
|
541
555
|
return request.form.transform_values { |v| form_value(v) }
|
|
542
556
|
end
|
|
543
557
|
{}
|
|
544
558
|
rescue StandardError
|
|
545
|
-
|
|
559
|
+
BAD_BODY
|
|
546
560
|
end
|
|
547
561
|
|
|
548
562
|
# String(value) for a parsed JSON value.
|
|
@@ -650,10 +664,40 @@ module Cronwatch
|
|
|
650
664
|
@env["REQUEST_METHOD"].to_s.upcase
|
|
651
665
|
end
|
|
652
666
|
|
|
653
|
-
# The path as the browser asked for it, still percent-encoded, mount
|
|
667
|
+
# The path as the browser asked for it, still percent-encoded, mount
|
|
668
|
+
# point included, with its dot segments resolved as `new URL` resolves
|
|
669
|
+
# them (a server may pass them through).
|
|
654
670
|
def pathname
|
|
655
671
|
path = "#{@env["SCRIPT_NAME"]}#{@env["PATH_INFO"]}"
|
|
656
|
-
path.empty? ? "/" : path
|
|
672
|
+
path.empty? ? "/" : Request.resolve_dots(path)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# A segment the URL standard reads as "." or "..", "%2e" standing for a period.
|
|
676
|
+
SINGLE_DOT = /\A(?:\.|%2e)\z/i
|
|
677
|
+
DOUBLE_DOT = /\A(?:\.|%2e){2}\z/i
|
|
678
|
+
|
|
679
|
+
# `path` with its "." and ".." segments taken out as the URL standard
|
|
680
|
+
# does: ".." takes the segment before it with it, and either one last
|
|
681
|
+
# leaves a trailing slash.
|
|
682
|
+
def self.resolve_dots(path)
|
|
683
|
+
return path unless path.start_with?("/")
|
|
684
|
+
|
|
685
|
+
segments = path.split("/", -1).drop(1)
|
|
686
|
+
return path unless segments.any? { |segment| SINGLE_DOT.match?(segment) || DOUBLE_DOT.match?(segment) }
|
|
687
|
+
|
|
688
|
+
out = []
|
|
689
|
+
segments.each_with_index do |segment, i|
|
|
690
|
+
last = i == segments.length - 1
|
|
691
|
+
if DOUBLE_DOT.match?(segment)
|
|
692
|
+
out.pop
|
|
693
|
+
out << "" if last
|
|
694
|
+
elsif SINGLE_DOT.match?(segment)
|
|
695
|
+
out << "" if last
|
|
696
|
+
else
|
|
697
|
+
out << segment
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
"/#{out.join("/")}"
|
|
657
701
|
end
|
|
658
702
|
|
|
659
703
|
def header(name)
|
|
@@ -121,9 +121,10 @@ module Cronwatch
|
|
|
121
121
|
|
|
122
122
|
# When the job was due within `from` to `to`. A cron's fires come from
|
|
123
123
|
# Schedule.fires_between. An interval is due one period after each run
|
|
124
|
-
# started
|
|
125
|
-
#
|
|
126
|
-
#
|
|
124
|
+
# started (after it finished, for a run that outlasted the period:
|
|
125
|
+
# Schedule.interval_due), and after the last run once a period for as
|
|
126
|
+
# long as nothing runs (the times a missed interval job keeps being
|
|
127
|
+
# asked for); with no run yet, from its next expected time.
|
|
127
128
|
def due_times(job, parsed, runs, from, to)
|
|
128
129
|
return DueTimes.new(times: [], dense: false) if parsed.nil?
|
|
129
130
|
|
|
@@ -131,13 +132,14 @@ module Cronwatch
|
|
|
131
132
|
every = parsed.every_ms
|
|
132
133
|
return DueTimes.new(times: [], dense: true) if (to - from).fdiv(every) > MAX_TICKS
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
# Oldest start first, runs that started together in the order given, as a stable sort leaves them.
|
|
136
|
+
ordered = runs.each_with_index.sort_by { |run, i| [run.started_at, i] }.map(&:first)
|
|
137
|
+
dues = ordered.map { |run| Schedule.interval_due(every, run.started_at, Evaluate.finished_at_of(run)) }
|
|
135
138
|
times = []
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
times << t if t >= from && t <= to
|
|
139
|
+
dues.each do |due|
|
|
140
|
+
times << due if due >= from && due <= to
|
|
139
141
|
end
|
|
140
|
-
t =
|
|
142
|
+
t = dues.empty? ? job.next_expected_at : dues.last
|
|
141
143
|
unless t.nil?
|
|
142
144
|
t += (from - t).fdiv(every).ceil * every if t < from
|
|
143
145
|
while t <= to
|
|
@@ -160,8 +162,10 @@ module Cronwatch
|
|
|
160
162
|
return nil if parsed.nil? || !open?(job, :missed)
|
|
161
163
|
|
|
162
164
|
grace = safely(0) { Evaluate.grace_ms(job.definition) }
|
|
163
|
-
last = job.last_run
|
|
164
|
-
|
|
165
|
+
last = job.last_run
|
|
166
|
+
unless last.nil?
|
|
167
|
+
return safely(nil) { Schedule.expectation(parsed, last.started_at, last.started_at, grace, Evaluate.finished_at_of(last))&.due_at }
|
|
168
|
+
end
|
|
165
169
|
|
|
166
170
|
past = times.select { |t| t + grace < now }
|
|
167
171
|
past.last
|
data/lib/cronwatch/zone.rb
CHANGED
|
@@ -61,6 +61,9 @@ module Cronwatch
|
|
|
61
61
|
guess = target + (target - civil_seconds(wall(target, timezone)))
|
|
62
62
|
seen = wall(guess, timezone)
|
|
63
63
|
if seen == wall
|
|
64
|
+
# Only an hour back is tried, as croner's fromTZ does, so a fall-back
|
|
65
|
+
# of another length (Lord Howe's 30 minutes) keeps croner's answer.
|
|
66
|
+
# Keep it so: Schedule's fall-back fires must match the SDK's.
|
|
64
67
|
earlier = guess - 3600
|
|
65
68
|
return wall(earlier, timezone) == wall ? earlier : guess
|
|
66
69
|
end
|