cronwatch 0.3.1 → 0.4.0
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 +19 -3
- data/lib/cronwatch/alerts/bugsnag.rb +69 -0
- data/lib/cronwatch/alerts/custom.rb +9 -2
- data/lib/cronwatch/alerts/datadog.rb +54 -0
- data/lib/cronwatch/alerts/email.rb +78 -0
- data/lib/cronwatch/alerts/honeybadger.rb +59 -0
- data/lib/cronwatch/alerts/mailgun.rb +36 -0
- data/lib/cronwatch/alerts/newrelic.rb +52 -0
- data/lib/cronwatch/alerts/postmark.rb +41 -0
- data/lib/cronwatch/alerts/provider.rb +159 -0
- data/lib/cronwatch/alerts/resend.rb +39 -0
- data/lib/cronwatch/alerts/rollbar.rb +53 -0
- data/lib/cronwatch/alerts/sendgrid.rb +41 -0
- data/lib/cronwatch/alerts/sentry.rb +94 -0
- data/lib/cronwatch/alerts/ses.rb +72 -0
- data/lib/cronwatch/alerts/sigv4.rb +84 -0
- data/lib/cronwatch/alerts/twilio.rb +206 -0
- data/lib/cronwatch/alerts/webhook.rb +7 -2
- data/lib/cronwatch/client.rb +473 -52
- data/lib/cronwatch/evaluate.rb +14 -1
- data/lib/cronwatch/format.rb +10 -4
- data/lib/cronwatch/http.rb +67 -6
- data/lib/cronwatch/job.rb +17 -0
- data/lib/cronwatch/pg_cron.rb +524 -0
- data/lib/cronwatch/run_handle.rb +203 -0
- data/lib/cronwatch/stores/active_record.rb +23 -0
- data/lib/cronwatch/stores/memory.rb +33 -10
- data/lib/cronwatch/types.rb +1 -0
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +38 -11
- data/lib/cronwatch/web/origin.rb +133 -0
- data/lib/cronwatch/web.rb +1 -0
- data/lib/cronwatch.rb +17 -1
- metadata +19 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
# A run recorded by JobHandle#start or found by JobHandle#resume, to finish
|
|
5
|
+
# later, perhaps in another process. Lines and metrics wait in the handle
|
|
6
|
+
# until #flush or #finish. Store failures go to on_error; none of these
|
|
7
|
+
# methods raises for them.
|
|
8
|
+
#
|
|
9
|
+
# run = SYNC.start(id: event_id)
|
|
10
|
+
# # later, perhaps elsewhere
|
|
11
|
+
# run = SYNC.resume(event_id)
|
|
12
|
+
# run.log("sent 40 emails")
|
|
13
|
+
# run.finish # or run.fail(error)
|
|
14
|
+
class RunHandle
|
|
15
|
+
# Raised by the client's side of finish when the store failed part way
|
|
16
|
+
# and nothing was recorded (already reported): the handle stays active.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
class Retry < StandardError; end
|
|
20
|
+
|
|
21
|
+
# `started_at` is nil when a resumed run could not be read.
|
|
22
|
+
attr_reader :id, :job, :started_at
|
|
23
|
+
|
|
24
|
+
# Made by the client (Client#run_handle), not by apps. `finish` and
|
|
25
|
+
# `flush` are the client's side of each; `inactive` says why finish has
|
|
26
|
+
# nothing to do, or is nil.
|
|
27
|
+
#
|
|
28
|
+
# @api private
|
|
29
|
+
def initialize(id:, job:, started_at:, inactive:, finish:, flush:, ignored:)
|
|
30
|
+
@id = id
|
|
31
|
+
@job = job
|
|
32
|
+
@started_at = started_at
|
|
33
|
+
@inactive = inactive
|
|
34
|
+
@finish = finish
|
|
35
|
+
@flush = flush
|
|
36
|
+
@ignored = ignored
|
|
37
|
+
# @state guards the flags and which recorder is current; @turn keeps
|
|
38
|
+
# flush and finish in order, one at a time.
|
|
39
|
+
@state = Mutex.new
|
|
40
|
+
@turn = Mutex.new
|
|
41
|
+
@recorder = fresh_recorder
|
|
42
|
+
@finished = !inactive.nil?
|
|
43
|
+
@finish_called = false
|
|
44
|
+
# The first Output::CAP characters of every line flushed from this
|
|
45
|
+
# handle, unredacted, or nil before the first flush. The stored output
|
|
46
|
+
# keeps only the tail, so without it an expect rule at finish would
|
|
47
|
+
# miss a line logged early, which run would have seen.
|
|
48
|
+
@head = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# False once finished, and from the start for a resumed run that already
|
|
52
|
+
# finished or does not exist.
|
|
53
|
+
def active?
|
|
54
|
+
@state.synchronize { !@finished }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Add a line of output, as JobContext#log does. Kept in the handle until flush or finish.
|
|
58
|
+
def log(*parts)
|
|
59
|
+
@state.synchronize { @recorder.context.log(*parts) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Report a number for this run. A later value for the same name replaces an earlier one.
|
|
63
|
+
def metric(name, value)
|
|
64
|
+
@state.synchronize { @recorder.context.metric(name, value) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def metrics(values = nil, **more)
|
|
68
|
+
@state.synchronize { @recorder.context.metrics(values, **more) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Append the lines and metrics added so far to the stored run, which must
|
|
72
|
+
# still be running and belong to this job. Output is redacted as it is
|
|
73
|
+
# written. A read, change and write of the run's row, written only while
|
|
74
|
+
# it is still running: two processes appending to one run at the same
|
|
75
|
+
# moment can lose one's lines, but a flush never undoes a finish. When
|
|
76
|
+
# the write fails the lines stay here for finish. The first 16 KB of
|
|
77
|
+
# everything flushed stay in the handle, so an expect rule at finish
|
|
78
|
+
# sees an early line as run would.
|
|
79
|
+
def flush
|
|
80
|
+
@turn.synchronize do
|
|
81
|
+
next if @flush.nil? || @state.synchronize { @finished }
|
|
82
|
+
|
|
83
|
+
# Lines logged while this waits on the store go to a new recorder.
|
|
84
|
+
taken = @state.synchronize do
|
|
85
|
+
current = @recorder
|
|
86
|
+
@recorder = fresh_recorder
|
|
87
|
+
current
|
|
88
|
+
end
|
|
89
|
+
lines = taken.output
|
|
90
|
+
values = taken.metrics
|
|
91
|
+
next if lines.nil? && values.empty?
|
|
92
|
+
|
|
93
|
+
if @flush.call(lines, values)
|
|
94
|
+
keep_head(taken.expect_text)
|
|
95
|
+
else
|
|
96
|
+
put_back(taken)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Finish the run, judge it like any other and send what that produces.
|
|
103
|
+
#
|
|
104
|
+
# finish # ok
|
|
105
|
+
# finish(status: "ok") # ok
|
|
106
|
+
# finish(error: e) # failed, recorded like an error run caught
|
|
107
|
+
# finish("text") # like run's return value: the output when
|
|
108
|
+
# finish(result: "text") # nothing was logged, checked by expect
|
|
109
|
+
#
|
|
110
|
+
# Returns the run as recorded, or nil when nothing was: the run was
|
|
111
|
+
# already finished (here or elsewhere), was not found, or belongs to
|
|
112
|
+
# another job, which is reported to on_error. When several processes
|
|
113
|
+
# finish one run, only the one whose write lands judges it. A store that
|
|
114
|
+
# fails is reported, nothing is recorded, and the handle stays active so
|
|
115
|
+
# finish can be called again.
|
|
116
|
+
def finish(outcome = nil)
|
|
117
|
+
was_inactive = nil
|
|
118
|
+
again = @state.synchronize do
|
|
119
|
+
next true if @finish_called
|
|
120
|
+
|
|
121
|
+
@finish_called = true
|
|
122
|
+
was_inactive = @finished
|
|
123
|
+
@finished = true
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
if again
|
|
127
|
+
@ignored.call("was already finished by this handle")
|
|
128
|
+
return nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@turn.synchronize do
|
|
132
|
+
if was_inactive
|
|
133
|
+
@ignored.call(@inactive)
|
|
134
|
+
next nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
begin
|
|
138
|
+
@finish.call(@state.synchronize { @recorder }, outcome, @head)
|
|
139
|
+
rescue Retry
|
|
140
|
+
reopen
|
|
141
|
+
nil
|
|
142
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
143
|
+
# An Interrupt or Timeout mid-finish: the run may still be running,
|
|
144
|
+
# so the handle stays open (lines kept) for finish to be called again.
|
|
145
|
+
reopen
|
|
146
|
+
raise
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# finish(error: error).
|
|
152
|
+
def fail(error)
|
|
153
|
+
finish({ error: error })
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# [failed, result, error] from what finish was given. A Hash with an
|
|
157
|
+
# :error key is a failure; a String, or a Hash's :result, is the result.
|
|
158
|
+
#
|
|
159
|
+
# @api private
|
|
160
|
+
def self.read_outcome(outcome)
|
|
161
|
+
case outcome
|
|
162
|
+
when String then [false, outcome, nil]
|
|
163
|
+
when Hash
|
|
164
|
+
key = [:error, "error"].find { |k| outcome.key?(k) }
|
|
165
|
+
return [true, nil, outcome[key]] if key
|
|
166
|
+
|
|
167
|
+
[false, outcome.key?(:result) ? outcome[:result] : outcome["result"], nil]
|
|
168
|
+
else [false, nil, nil]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def fresh_recorder
|
|
175
|
+
RunRecorder.new(Run.new(id: @id, job: @job, started_at: @started_at || 0), nil)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A flush that could not write: its lines go back ahead of any logged since.
|
|
179
|
+
def put_back(taken)
|
|
180
|
+
@state.synchronize do
|
|
181
|
+
later = @recorder
|
|
182
|
+
@recorder = fresh_recorder
|
|
183
|
+
[taken.expect_text, later.expect_text].each { |text| @recorder.log(text) unless text.nil? }
|
|
184
|
+
taken.metrics.merge(later.metrics).each { |name, value| @recorder.metric(name, value) }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A finish that recorded nothing leaves the handle active, to be finished again.
|
|
189
|
+
def reopen
|
|
190
|
+
@state.synchronize do
|
|
191
|
+
@finish_called = false
|
|
192
|
+
@finished = false
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Keeps the start of what a flush wrote, up to the cap, for expect at finish.
|
|
197
|
+
def keep_head(text)
|
|
198
|
+
return if text.nil? || (!@head.nil? && JS.length16(@head) >= Output::CAP)
|
|
199
|
+
|
|
200
|
+
@head = JS.head16(@head.nil? || @head.empty? ? text : "#{@head}\n#{text}", Output::CAP)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -216,6 +216,18 @@ module Cronwatch
|
|
|
216
216
|
nil
|
|
217
217
|
end
|
|
218
218
|
|
|
219
|
+
# update_run, only while the stored status is one of `from_statuses`,
|
|
220
|
+
# in one statement (sql.ts updateRunIfSql). Returns whether it wrote.
|
|
221
|
+
def update_run_if(run, from_statuses)
|
|
222
|
+
statuses = Array(from_statuses).map(&:to_s)
|
|
223
|
+
return false if statuses.empty?
|
|
224
|
+
|
|
225
|
+
binds = [run.status.to_s, run.finished_at, run.duration_ms, run.error, run.output,
|
|
226
|
+
JS.json(run.metrics || {}), run.id, *statuses]
|
|
227
|
+
changed = with_connection { |conn| conn.exec_update(update_run_if_sql(conn, statuses.length), NAME, binds) }
|
|
228
|
+
changed.to_i.positive?
|
|
229
|
+
end
|
|
230
|
+
|
|
219
231
|
def get_run(id)
|
|
220
232
|
row = read(:get_run, [id]).first
|
|
221
233
|
row && row_to_run(row)
|
|
@@ -313,6 +325,17 @@ module Cronwatch
|
|
|
313
325
|
(@statements[dialect] ||= statements(dialect)).fetch(key)
|
|
314
326
|
end
|
|
315
327
|
|
|
328
|
+
# sql.ts `updateRunIfSql`: update_run's statement, only while the
|
|
329
|
+
# stored status is one of `count` statuses, bound value by value.
|
|
330
|
+
def update_run_if_sql(conn, count)
|
|
331
|
+
text = "UPDATE #{@prefix}runs SET status = ?, finished_at = ?, duration_ms = ?, error = ?, output = ?, metrics = ? " \
|
|
332
|
+
"WHERE id = ? AND status IN (#{Array.new(count, "?").join(", ")})"
|
|
333
|
+
return text unless self.class.dialect(conn) == :postgres
|
|
334
|
+
|
|
335
|
+
n = 0
|
|
336
|
+
text.gsub("?") { "$#{n += 1}" }
|
|
337
|
+
end
|
|
338
|
+
|
|
316
339
|
# sql.ts `statements`: the same text, with `?` numbered for Postgres.
|
|
317
340
|
def statements(dialect)
|
|
318
341
|
p = @prefix
|
|
@@ -7,7 +7,8 @@ module Cronwatch
|
|
|
7
7
|
# so a missed run cannot be noticed across one.
|
|
8
8
|
#
|
|
9
9
|
# Every store answers the same methods: init (optional), upsert_job,
|
|
10
|
-
# get_job, list_jobs, delete_job, insert_run, update_run,
|
|
10
|
+
# get_job, list_jobs, delete_job, insert_run, update_run, update_run_if
|
|
11
|
+
# (optional: without it the client reads the run, then writes it), get_run,
|
|
11
12
|
# list_runs, last_run, running_runs, get_state, set_state, compare_and_set_state
|
|
12
13
|
# (optional: without it the client falls back to set_state), prune and close
|
|
13
14
|
# (optional). They take and return the types in types.rb.
|
|
@@ -56,8 +57,11 @@ module Cronwatch
|
|
|
56
57
|
nil
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
# Like SQL's primary key: an id already recorded is refused, never overwritten.
|
|
59
61
|
def insert_run(run)
|
|
60
62
|
sync do
|
|
63
|
+
raise "run #{run.id} already exists" if @runs.key?(run.id)
|
|
64
|
+
|
|
61
65
|
@runs[run.id] = clone(run, Run)
|
|
62
66
|
@order[run.id] = (@seq += 1)
|
|
63
67
|
end
|
|
@@ -70,19 +74,25 @@ module Cronwatch
|
|
|
70
74
|
existing = @runs[run.id]
|
|
71
75
|
next unless existing
|
|
72
76
|
|
|
73
|
-
|
|
74
|
-
@runs[run.id] = existing.dup.tap do |r|
|
|
75
|
-
r.status = copy.status
|
|
76
|
-
r.finished_at = copy.finished_at
|
|
77
|
-
r.duration_ms = copy.duration_ms
|
|
78
|
-
r.error = copy.error
|
|
79
|
-
r.output = copy.output
|
|
80
|
-
r.metrics = copy.metrics
|
|
81
|
-
end
|
|
77
|
+
@runs[run.id] = finished_fields(existing, run)
|
|
82
78
|
end
|
|
83
79
|
nil
|
|
84
80
|
end
|
|
85
81
|
|
|
82
|
+
# update_run, only while the stored run's status is one of
|
|
83
|
+
# `from_statuses`, in one step. Returns whether it wrote. What lets
|
|
84
|
+
# exactly one of several processes finishing a run evaluate it.
|
|
85
|
+
def update_run_if(run, from_statuses)
|
|
86
|
+
statuses = Array(from_statuses).map(&:to_sym)
|
|
87
|
+
sync do
|
|
88
|
+
existing = @runs[run.id]
|
|
89
|
+
next false unless existing && statuses.include?(existing.status)
|
|
90
|
+
|
|
91
|
+
@runs[run.id] = finished_fields(existing, run)
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
86
96
|
def get_run(id)
|
|
87
97
|
sync { (run = @runs[id]) && clone(run, Run) }
|
|
88
98
|
end
|
|
@@ -149,6 +159,19 @@ module Cronwatch
|
|
|
149
159
|
|
|
150
160
|
private
|
|
151
161
|
|
|
162
|
+
# `existing` with the fields an update writes taken from `run`.
|
|
163
|
+
def finished_fields(existing, run)
|
|
164
|
+
copy = clone(run, Run)
|
|
165
|
+
existing.dup.tap do |r|
|
|
166
|
+
r.status = copy.status
|
|
167
|
+
r.finished_at = copy.finished_at
|
|
168
|
+
r.duration_ms = copy.duration_ms
|
|
169
|
+
r.error = copy.error
|
|
170
|
+
r.output = copy.output
|
|
171
|
+
r.metrics = copy.metrics
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
152
175
|
def sync(&block)
|
|
153
176
|
@lock.synchronize(&block)
|
|
154
177
|
end
|
data/lib/cronwatch/types.rb
CHANGED
|
@@ -226,6 +226,7 @@ module Cronwatch
|
|
|
226
226
|
run = Naming.fetch(hash, "run")
|
|
227
227
|
details = Naming.from_json_value(Naming.fetch(hash, "details") || {})
|
|
228
228
|
details[:after] = details[:after].map(&:to_sym) if details[:after].is_a?(Array)
|
|
229
|
+
details[:reason] = details[:reason].to_sym if details[:reason].is_a?(String)
|
|
229
230
|
alert = new(
|
|
230
231
|
type: Naming.fetch(hash, "type")&.to_sym,
|
|
231
232
|
run: run && Run.from_h(run),
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "digest"
|
|
5
5
|
require "securerandom"
|
|
6
|
+
require "uri"
|
|
6
7
|
|
|
7
8
|
module Cronwatch
|
|
8
9
|
# The dashboard and the small JSON API, as a Rack app: the SDK's routes
|
|
@@ -26,6 +27,20 @@ module Cronwatch
|
|
|
26
27
|
# client's cron_secret as a bearer, for a platform cron.
|
|
27
28
|
# base_path: where the app is mounted, so links resolve. Defaults to the
|
|
28
29
|
# mount point (SCRIPT_NAME), which is right under Rails' `mount`.
|
|
30
|
+
# origin: the public origin the dashboard is served from, such as
|
|
31
|
+
# "https://app.example.com". By default the request's own origin
|
|
32
|
+
# is used, as Rack reads it (Rack::Request#base_url, which follows
|
|
33
|
+
# X-Forwarded-Proto and X-Forwarded-Host the way Rails does). Set
|
|
34
|
+
# this to pin it, for an app behind a proxy that does not set
|
|
35
|
+
# those headers or one that should not trust them. Used in place
|
|
36
|
+
# of the request's origin for the cross-site check on writes, the
|
|
37
|
+
# sign-in cookie's Secure flag, the Referer the redirect back after
|
|
38
|
+
# a form follows, and the development sign-in line. Read as
|
|
39
|
+
# `new URL(value).origin` reads it (Web::Origin) and normalised to
|
|
40
|
+
# scheme://host[:port]; anything that is not an absolute http or
|
|
41
|
+
# https URL, or has a port outside 1 to 65535, raises
|
|
42
|
+
# ArgumentError here. "" counts as unset. Behind more than one
|
|
43
|
+
# proxy set it: Rack takes the last of several forwarded values.
|
|
29
44
|
class Web
|
|
30
45
|
# Tells "token not given" (read CRONWATCH_TOKEN) from "token: nil" (open on purpose).
|
|
31
46
|
UNSET = Object.new.freeze
|
|
@@ -46,8 +61,9 @@ module Cronwatch
|
|
|
46
61
|
DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
47
62
|
RADIX = { "x" => 16, "o" => 8, "b" => 2 }.freeze
|
|
48
63
|
|
|
49
|
-
def initialize(client = nil, token: UNSET, base_path: nil)
|
|
64
|
+
def initialize(client = nil, token: UNSET, base_path: nil, origin: nil)
|
|
50
65
|
@client = client
|
|
66
|
+
@origin = Web.configured_origin(origin)
|
|
51
67
|
@opted_out = token.nil?
|
|
52
68
|
given = token.equal?(UNSET) ? nil : token
|
|
53
69
|
@token = @opted_out ? nil : [given, ENV.fetch("CRONWATCH_TOKEN", nil)].map(&:to_s).find { |t| !t.empty? }
|
|
@@ -77,6 +93,14 @@ module Cronwatch
|
|
|
77
93
|
"Sign in: #{origin}#{base}/?token=#{token}"
|
|
78
94
|
end
|
|
79
95
|
|
|
96
|
+
# The `origin` option, normalised to scheme://host[:port], or nil when
|
|
97
|
+
# unset. Raises on a value that is not an http or https origin, so a typo
|
|
98
|
+
# fails at startup.
|
|
99
|
+
# Read as the SDK's `new URL(value).origin` reads it (Web::Origin).
|
|
100
|
+
def self.configured_origin(value)
|
|
101
|
+
Origin.parse(value)
|
|
102
|
+
end
|
|
103
|
+
|
|
80
104
|
# The client given, or Cronwatch.client when none was.
|
|
81
105
|
def client
|
|
82
106
|
@client || Cronwatch.client
|
|
@@ -142,7 +166,7 @@ module Cronwatch
|
|
|
142
166
|
end
|
|
143
167
|
unless query.nil?
|
|
144
168
|
# Move the token from the URL into a cookie so it is not in history or logs.
|
|
145
|
-
secure = request.https
|
|
169
|
+
secure = public_origin(request).start_with?("https:") ? "; Secure" : ""
|
|
146
170
|
return redirect(request.pathname + request.search_without("token"),
|
|
147
171
|
"set-cookie" => "#{COOKIE}=#{cookie_value(@token)}; Path=#{base.empty? ? "/" : base}; HttpOnly; SameSite=Lax; Max-Age=#{COOKIE_MAX_AGE}#{secure}")
|
|
148
172
|
end
|
|
@@ -255,7 +279,7 @@ module Cronwatch
|
|
|
255
279
|
end
|
|
256
280
|
return unless first
|
|
257
281
|
|
|
258
|
-
$stdout.puts(Web.development_sign_in_line(request
|
|
282
|
+
$stdout.puts(Web.development_sign_in_line(public_origin(request), base, @token))
|
|
259
283
|
$stdout.flush
|
|
260
284
|
end
|
|
261
285
|
|
|
@@ -295,7 +319,7 @@ module Cronwatch
|
|
|
295
319
|
# a page cannot forge either. Non-browser clients send neither.
|
|
296
320
|
def cross_site?(request)
|
|
297
321
|
origin = request.header("origin")
|
|
298
|
-
return true if !origin.nil? && origin != request
|
|
322
|
+
return true if !origin.nil? && origin != public_origin(request)
|
|
299
323
|
|
|
300
324
|
site = request.header("sec-fetch-site")
|
|
301
325
|
!site.nil? && site != "same-origin" && site != "none"
|
|
@@ -378,7 +402,12 @@ module Cronwatch
|
|
|
378
402
|
|
|
379
403
|
def redirect_back(request, base)
|
|
380
404
|
referer = request.header("referer") || ""
|
|
381
|
-
redirect(referer.start_with?("#{request
|
|
405
|
+
redirect(referer.start_with?("#{public_origin(request)}/") ? referer : "#{base}/")
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# The origin a browser sees: the `origin` option when set, otherwise the request's own.
|
|
409
|
+
def public_origin(request)
|
|
410
|
+
@origin || request.origin
|
|
382
411
|
end
|
|
383
412
|
|
|
384
413
|
def api(body, status = 200, headers = {})
|
|
@@ -426,13 +455,11 @@ module Cronwatch
|
|
|
426
455
|
@env[key]
|
|
427
456
|
end
|
|
428
457
|
|
|
429
|
-
# scheme://host[:port], the page's origin
|
|
458
|
+
# scheme://host[:port], the page's origin, lowercased as a browser
|
|
459
|
+
# writes it in Origin and Referer (Rack's base_url keeps the case of
|
|
460
|
+
# the Host or X-Forwarded-Host it read).
|
|
430
461
|
def origin
|
|
431
|
-
@rack.base_url
|
|
432
|
-
end
|
|
433
|
-
|
|
434
|
-
def https?
|
|
435
|
-
@rack.scheme == "https"
|
|
462
|
+
@rack.base_url.downcase
|
|
436
463
|
end
|
|
437
464
|
|
|
438
465
|
# URLSearchParams#get: the first value, or nil.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Cronwatch
|
|
6
|
+
class Web
|
|
7
|
+
# Reads the `origin` option as the SDK's `new URL(value).origin` does:
|
|
8
|
+
# whitespace around it and tabs or line breaks in it are dropped, slashes
|
|
9
|
+
# after the scheme may be missing or backslashes, credentials are
|
|
10
|
+
# ignored, the host is lowercased (percent escapes decoded, IPv4 numbers
|
|
11
|
+
# written out, IPv6 compressed, a non-ASCII host converted to punycode)
|
|
12
|
+
# and a default port is left out. A port outside 1 to 65535 is refused.
|
|
13
|
+
module Origin
|
|
14
|
+
ABSOLUTE = "routes: origin must be an absolute URL such as \"https://app.example.com\", got %s"
|
|
15
|
+
FORBIDDEN_HOST = /[\x00-\x20#%\/:<>?@\[\\\]^|\x7F]/
|
|
16
|
+
DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# scheme://host[:port], or nil for nil or "". Raises ArgumentError otherwise.
|
|
21
|
+
def parse(value)
|
|
22
|
+
return nil if value.nil? || value == ""
|
|
23
|
+
raise ArgumentError, format(ABSOLUTE, JS.json(value)) unless value.is_a?(String)
|
|
24
|
+
|
|
25
|
+
text = value.sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "").delete("\t\n\r")
|
|
26
|
+
match = text.match(/\A([A-Za-z][A-Za-z0-9+.\-]*):(.*)\z/m)
|
|
27
|
+
raise ArgumentError, format(ABSOLUTE, JS.json(value)) unless match
|
|
28
|
+
|
|
29
|
+
scheme = match[1].downcase
|
|
30
|
+
unless DEFAULT_PORTS.key?(scheme)
|
|
31
|
+
raise ArgumentError, "routes: origin must be http or https, got #{JS.json(value)}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
authority = match[2].sub(%r{\A[/\\]*}, "")[%r{\A[^/\\?#]*}]
|
|
35
|
+
authority = authority.split("@", -1).last.to_s
|
|
36
|
+
host, port = split_port(authority, value)
|
|
37
|
+
host = read_host(host, value)
|
|
38
|
+
port = port.nil? || port == DEFAULT_PORTS[scheme] ? "" : ":#{port}"
|
|
39
|
+
"#{scheme}://#{host}#{port}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# [host, port] with the scheme's default port when none is given.
|
|
43
|
+
def split_port(authority, value)
|
|
44
|
+
if authority.start_with?("[")
|
|
45
|
+
close = authority.index("]") or raise ArgumentError, format(ABSOLUTE, JS.json(value))
|
|
46
|
+
host = authority[0..close]
|
|
47
|
+
rest = authority[(close + 1)..]
|
|
48
|
+
else
|
|
49
|
+
host, colon, rest = authority.rpartition(":")
|
|
50
|
+
host, rest = rest, "" if colon.empty?
|
|
51
|
+
rest = ":#{rest}" unless colon.empty?
|
|
52
|
+
end
|
|
53
|
+
return [host, nil] if rest.empty? || rest == ":"
|
|
54
|
+
raise ArgumentError, format(ABSOLUTE, JS.json(value)) unless rest.match?(/\A:[0-9]+\z/)
|
|
55
|
+
|
|
56
|
+
port = rest[1..].to_i
|
|
57
|
+
unless port.between?(1, 65_535)
|
|
58
|
+
raise ArgumentError, "routes: origin has a port outside 1 to 65535, got #{JS.json(value)}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
[host, port]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def read_host(host, value)
|
|
65
|
+
invalid = -> { raise ArgumentError, format(ABSOLUTE, JS.json(value)) }
|
|
66
|
+
invalid.call if host.empty?
|
|
67
|
+
if host.start_with?("[")
|
|
68
|
+
invalid.call unless host.end_with?("]")
|
|
69
|
+
address = begin
|
|
70
|
+
IPAddr.new(host[1..-2])
|
|
71
|
+
rescue IPAddr::Error
|
|
72
|
+
invalid.call
|
|
73
|
+
end
|
|
74
|
+
invalid.call unless address.ipv6?
|
|
75
|
+
return "[#{address}]"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
decoded = host.b.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
|
|
79
|
+
invalid.call unless decoded.valid_encoding?
|
|
80
|
+
decoded = to_ascii(decoded, value) unless decoded.ascii_only?
|
|
81
|
+
decoded = decoded.downcase
|
|
82
|
+
invalid.call if decoded.empty? || decoded.match?(FORBIDDEN_HOST)
|
|
83
|
+
ipv4 = ipv4(decoded, invalid)
|
|
84
|
+
ipv4 || decoded
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# WHATWG's IPv4 parser, for a host whose last label is a number:
|
|
88
|
+
# "127.1" and "0x7f.1" are 127.0.0.1. nil for a host that is a name.
|
|
89
|
+
def ipv4(host, invalid)
|
|
90
|
+
parts = host.split(".", -1)
|
|
91
|
+
parts.pop if parts.length > 1 && parts.last.empty?
|
|
92
|
+
return nil unless number?(parts.last)
|
|
93
|
+
|
|
94
|
+
invalid.call if parts.length > 4
|
|
95
|
+
numbers = parts.map { |part| number(part) || invalid.call }
|
|
96
|
+
last = numbers.pop
|
|
97
|
+
invalid.call if numbers.any? { |n| n > 255 } || last >= 256**(4 - numbers.length)
|
|
98
|
+
address = numbers.each_with_index.sum { |n, i| n * (256**(3 - i)) } + last
|
|
99
|
+
[24, 16, 8, 0].map { |shift| (address >> shift) & 255 }.join(".")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def number?(part)
|
|
103
|
+
part.match?(/\A[0-9]+\z/) || part.match?(/\A0[xX][0-9A-Fa-f]*\z/)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def number(part)
|
|
107
|
+
return nil if part.empty?
|
|
108
|
+
return (part.length == 2 ? 0 : part[2..].hex) if part.match?(/\A0[xX][0-9A-Fa-f]*\z/)
|
|
109
|
+
return part.to_i(8) if part.match?(/\A0[0-7]+\z/)
|
|
110
|
+
|
|
111
|
+
part.match?(/\A[0-9]+\z/) && !part.match?(/\A0[0-9]+\z/) ? part.to_i : nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A non-ASCII host in punycode, through URI::IDNA when this Ruby has it,
|
|
115
|
+
# else the simpleidn gem, else Addressable's (already loaded by many apps).
|
|
116
|
+
def to_ascii(host, value)
|
|
117
|
+
normalized = host.unicode_normalize(:nfc).downcase
|
|
118
|
+
return URI::IDNA.to_ascii(normalized) if defined?(URI::IDNA) && URI::IDNA.respond_to?(:to_ascii)
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
require "simpleidn" unless defined?(::SimpleIDN)
|
|
122
|
+
rescue LoadError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
return ::SimpleIDN.to_ascii(normalized) if defined?(::SimpleIDN)
|
|
126
|
+
return ::Addressable::IDNA.to_ascii(normalized) if defined?(::Addressable::IDNA)
|
|
127
|
+
|
|
128
|
+
raise ArgumentError, "routes: origin has a host that is not ASCII, got #{JS.json(value)}; write it in punycode " \
|
|
129
|
+
"(xn--...) or add the simpleidn gem"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/cronwatch/web.rb
CHANGED
data/lib/cronwatch.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "cronwatch/evaluate"
|
|
|
13
13
|
require_relative "cronwatch/format"
|
|
14
14
|
require_relative "cronwatch/serialize"
|
|
15
15
|
require_relative "cronwatch/job"
|
|
16
|
+
require_relative "cronwatch/run_handle"
|
|
16
17
|
require_relative "cronwatch/http"
|
|
17
18
|
require_relative "cronwatch/stores/memory"
|
|
18
19
|
require_relative "cronwatch/alerts/console"
|
|
@@ -20,6 +21,21 @@ require_relative "cronwatch/alerts/custom"
|
|
|
20
21
|
require_relative "cronwatch/alerts/slack"
|
|
21
22
|
require_relative "cronwatch/alerts/discord"
|
|
22
23
|
require_relative "cronwatch/alerts/webhook"
|
|
24
|
+
require_relative "cronwatch/alerts/provider"
|
|
25
|
+
require_relative "cronwatch/alerts/email"
|
|
26
|
+
require_relative "cronwatch/alerts/sigv4"
|
|
27
|
+
require_relative "cronwatch/alerts/resend"
|
|
28
|
+
require_relative "cronwatch/alerts/postmark"
|
|
29
|
+
require_relative "cronwatch/alerts/sendgrid"
|
|
30
|
+
require_relative "cronwatch/alerts/mailgun"
|
|
31
|
+
require_relative "cronwatch/alerts/ses"
|
|
32
|
+
require_relative "cronwatch/alerts/twilio"
|
|
33
|
+
require_relative "cronwatch/alerts/sentry"
|
|
34
|
+
require_relative "cronwatch/alerts/honeybadger"
|
|
35
|
+
require_relative "cronwatch/alerts/datadog"
|
|
36
|
+
require_relative "cronwatch/alerts/rollbar"
|
|
37
|
+
require_relative "cronwatch/alerts/bugsnag"
|
|
38
|
+
require_relative "cronwatch/alerts/newrelic"
|
|
23
39
|
require_relative "cronwatch/client"
|
|
24
40
|
|
|
25
41
|
# Cron and scheduled-job monitoring that lives inside your app.
|
|
@@ -32,7 +48,7 @@ require_relative "cronwatch/client"
|
|
|
32
48
|
module Cronwatch
|
|
33
49
|
# The options Cronwatch.configure sets. Anything left unset takes the client's default.
|
|
34
50
|
class Configuration
|
|
35
|
-
OPTIONS = %i[store alerts triage cron_secret retention defaults redact deliver now on_error].freeze
|
|
51
|
+
OPTIONS = %i[store alerts triage cron_secret retention defaults redact deliver now on_error sources].freeze
|
|
36
52
|
|
|
37
53
|
attr_accessor(*OPTIONS)
|
|
38
54
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cronwatch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jon C. Phillips
|
|
@@ -35,10 +35,25 @@ files:
|
|
|
35
35
|
- lib/cronwatch.rb
|
|
36
36
|
- lib/cronwatch/abort_signal.rb
|
|
37
37
|
- lib/cronwatch/active_record.rb
|
|
38
|
+
- lib/cronwatch/alerts/bugsnag.rb
|
|
38
39
|
- lib/cronwatch/alerts/console.rb
|
|
39
40
|
- lib/cronwatch/alerts/custom.rb
|
|
41
|
+
- lib/cronwatch/alerts/datadog.rb
|
|
40
42
|
- lib/cronwatch/alerts/discord.rb
|
|
43
|
+
- lib/cronwatch/alerts/email.rb
|
|
44
|
+
- lib/cronwatch/alerts/honeybadger.rb
|
|
45
|
+
- lib/cronwatch/alerts/mailgun.rb
|
|
46
|
+
- lib/cronwatch/alerts/newrelic.rb
|
|
47
|
+
- lib/cronwatch/alerts/postmark.rb
|
|
48
|
+
- lib/cronwatch/alerts/provider.rb
|
|
49
|
+
- lib/cronwatch/alerts/resend.rb
|
|
50
|
+
- lib/cronwatch/alerts/rollbar.rb
|
|
51
|
+
- lib/cronwatch/alerts/sendgrid.rb
|
|
52
|
+
- lib/cronwatch/alerts/sentry.rb
|
|
53
|
+
- lib/cronwatch/alerts/ses.rb
|
|
54
|
+
- lib/cronwatch/alerts/sigv4.rb
|
|
41
55
|
- lib/cronwatch/alerts/slack.rb
|
|
56
|
+
- lib/cronwatch/alerts/twilio.rb
|
|
42
57
|
- lib/cronwatch/alerts/webhook.rb
|
|
43
58
|
- lib/cronwatch/client.rb
|
|
44
59
|
- lib/cronwatch/cron_pattern.rb
|
|
@@ -52,11 +67,13 @@ files:
|
|
|
52
67
|
- lib/cronwatch/js.rb
|
|
53
68
|
- lib/cronwatch/monitored.rb
|
|
54
69
|
- lib/cronwatch/output.rb
|
|
70
|
+
- lib/cronwatch/pg_cron.rb
|
|
55
71
|
- lib/cronwatch/rails.rb
|
|
56
72
|
- lib/cronwatch/rails/active_job.rb
|
|
57
73
|
- lib/cronwatch/rails/check_job.rb
|
|
58
74
|
- lib/cronwatch/rails/railtie.rb
|
|
59
75
|
- lib/cronwatch/rails/tasks.rb
|
|
76
|
+
- lib/cronwatch/run_handle.rb
|
|
60
77
|
- lib/cronwatch/schedule.rb
|
|
61
78
|
- lib/cronwatch/scheduler.rb
|
|
62
79
|
- lib/cronwatch/serialize.rb
|
|
@@ -72,6 +89,7 @@ files:
|
|
|
72
89
|
- lib/cronwatch/web.rb
|
|
73
90
|
- lib/cronwatch/web/app.rb
|
|
74
91
|
- lib/cronwatch/web/html.rb
|
|
92
|
+
- lib/cronwatch/web/origin.rb
|
|
75
93
|
- lib/cronwatch/zone.rb
|
|
76
94
|
- lib/generators/cronwatch/install/install_generator.rb
|
|
77
95
|
homepage: https://cronwatch.dev
|