cronwatch 0.3.1 → 0.5.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/schedule.rb +28 -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 +97 -20
- data/lib/cronwatch/web/html.rb +395 -140
- data/lib/cronwatch/web/icons.rb +19 -0
- data/lib/cronwatch/web/origin.rb +133 -0
- data/lib/cronwatch/web/pwa.rb +137 -0
- data/lib/cronwatch/web/timeline.rb +476 -0
- data/lib/cronwatch/web.rb +4 -0
- data/lib/cronwatch.rb +17 -1
- metadata +28 -3
|
@@ -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
|
data/lib/cronwatch/schedule.rb
CHANGED
|
@@ -93,6 +93,34 @@ module Cronwatch
|
|
|
93
93
|
nil
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# Every fire of a cron strictly after `from` and at or before `to`,
|
|
97
|
+
# ascending, or nil when there are more than `limit`. Walks the fires in
|
|
98
|
+
# batches, as firesBetween in schedule.ts asks croner for them, and drops
|
|
99
|
+
# any that do not move forward (see fire_after).
|
|
100
|
+
def fires_between(parsed, from, to, limit)
|
|
101
|
+
raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
|
|
102
|
+
|
|
103
|
+
out = []
|
|
104
|
+
probe = from
|
|
105
|
+
last = from
|
|
106
|
+
1000.times do
|
|
107
|
+
batch = next_runs(parsed, [limit + 1 - out.length, 24].min, probe)
|
|
108
|
+
return out if batch.empty?
|
|
109
|
+
|
|
110
|
+
batch.each do |t|
|
|
111
|
+
next if t <= last
|
|
112
|
+
return out if t > to
|
|
113
|
+
|
|
114
|
+
out << t
|
|
115
|
+
last = t
|
|
116
|
+
return nil if out.length > limit
|
|
117
|
+
end
|
|
118
|
+
finish = batch.last
|
|
119
|
+
probe = finish > probe ? finish : probe + 3_600_000
|
|
120
|
+
end
|
|
121
|
+
out
|
|
122
|
+
end
|
|
123
|
+
|
|
96
124
|
# Up to `count` fires, each found from the one before, as croner's nextRuns.
|
|
97
125
|
def next_runs(parsed, count, from)
|
|
98
126
|
runs = []
|
|
@@ -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,16 +27,37 @@ 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
|
|
32
47
|
COOKIE = "cronwatch_token"
|
|
33
48
|
DEFAULT_RUNS = 20
|
|
34
49
|
MAX_RUNS = 500
|
|
50
|
+
# Runs per job the board reads in one go: the table's sparkline, and most jobs' lanes.
|
|
51
|
+
BOARD_PAGE_RUNS = 20
|
|
35
52
|
COOKIE_MAX_AGE = 60 * 60 * 24 * 30
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
# 'self' only for what the app shell needs: app.js (which registers the
|
|
55
|
+
# service worker and nothing else), the manifest, the worker and the icons.
|
|
56
|
+
# No inline script, and the pages work without any.
|
|
57
|
+
CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; " \
|
|
58
|
+
"manifest-src 'self'; worker-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
|
|
59
|
+
# For the SVG icons, should one be opened on its own.
|
|
60
|
+
ASSET_CSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
|
|
39
61
|
# same-origin rather than no-referrer: under no-referrer browsers send
|
|
40
62
|
# `Origin: null` on form posts, which the CSRF check would refuse, and the
|
|
41
63
|
# forms redirect back to the page named by the same-origin Referer.
|
|
@@ -46,8 +68,9 @@ module Cronwatch
|
|
|
46
68
|
DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
47
69
|
RADIX = { "x" => 16, "o" => 8, "b" => 2 }.freeze
|
|
48
70
|
|
|
49
|
-
def initialize(client = nil, token: UNSET, base_path: nil)
|
|
71
|
+
def initialize(client = nil, token: UNSET, base_path: nil, origin: nil)
|
|
50
72
|
@client = client
|
|
73
|
+
@origin = Web.configured_origin(origin)
|
|
51
74
|
@opted_out = token.nil?
|
|
52
75
|
given = token.equal?(UNSET) ? nil : token
|
|
53
76
|
@token = @opted_out ? nil : [given, ENV.fetch("CRONWATCH_TOKEN", nil)].map(&:to_s).find { |t| !t.empty? }
|
|
@@ -77,6 +100,14 @@ module Cronwatch
|
|
|
77
100
|
"Sign in: #{origin}#{base}/?token=#{token}"
|
|
78
101
|
end
|
|
79
102
|
|
|
103
|
+
# The `origin` option, normalised to scheme://host[:port], or nil when
|
|
104
|
+
# unset. Raises on a value that is not an http or https origin, so a typo
|
|
105
|
+
# fails at startup.
|
|
106
|
+
# Read as the SDK's `new URL(value).origin` reads it (Web::Origin).
|
|
107
|
+
def self.configured_origin(value)
|
|
108
|
+
Origin.parse(value)
|
|
109
|
+
end
|
|
110
|
+
|
|
80
111
|
# The client given, or Cronwatch.client when none was.
|
|
81
112
|
def client
|
|
82
113
|
@client || Cronwatch.client
|
|
@@ -112,6 +143,18 @@ module Cronwatch
|
|
|
112
143
|
|
|
113
144
|
announce(request, base) if @generated && !@announced
|
|
114
145
|
|
|
146
|
+
# The app shell: the manifest, icons, service worker, app.js and the
|
|
147
|
+
# offline page. Served to anyone, since a browser fetches some of it
|
|
148
|
+
# without cookies and none of it says anything about the jobs.
|
|
149
|
+
if %w[GET HEAD].include?(method)
|
|
150
|
+
if path == "/offline"
|
|
151
|
+
return html(HTML.message_page("You are offline", "CronWatch shows live data from your app, so it needs a connection.", base), 200, "no-cache")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
asset = PWA.asset(path, base)
|
|
155
|
+
return shell(asset, base) if asset
|
|
156
|
+
end
|
|
157
|
+
|
|
115
158
|
# No token outside development: fail closed.
|
|
116
159
|
if !@token && !@opted_out
|
|
117
160
|
return wants_html ? html(HTML.message_page("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token: to Cronwatch::Web.new), or pass token: nil to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503)
|
|
@@ -136,13 +179,13 @@ module Cronwatch
|
|
|
136
179
|
end
|
|
137
180
|
unless cron_secret_ok || token_ok
|
|
138
181
|
if @generated
|
|
139
|
-
return wants_html ? html(HTML.message_page("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401)
|
|
182
|
+
return wants_html ? html(HTML.message_page("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base, sign_in: true), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401)
|
|
140
183
|
end
|
|
141
|
-
return wants_html ? html(HTML.message_page("Sign in", "Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.", base), 401) : api({ ok: false, error: "Unauthorized" }, 401)
|
|
184
|
+
return wants_html ? html(HTML.message_page("Sign in", "Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.", base, sign_in: true), 401) : api({ ok: false, error: "Unauthorized" }, 401)
|
|
142
185
|
end
|
|
143
186
|
unless query.nil?
|
|
144
187
|
# Move the token from the URL into a cookie so it is not in history or logs.
|
|
145
|
-
secure = request.https
|
|
188
|
+
secure = public_origin(request).start_with?("https:") ? "; Secure" : ""
|
|
146
189
|
return redirect(request.pathname + request.search_without("token"),
|
|
147
190
|
"set-cookie" => "#{COOKIE}=#{cookie_value(@token)}; Path=#{base.empty? ? "/" : base}; HttpOnly; SameSite=Lax; Max-Age=#{COOKIE_MAX_AGE}#{secure}")
|
|
148
191
|
end
|
|
@@ -155,15 +198,20 @@ module Cronwatch
|
|
|
155
198
|
|
|
156
199
|
# HTML
|
|
157
200
|
if method == "GET" && path == "/"
|
|
158
|
-
entries = cw.jobs_with_runs(
|
|
201
|
+
entries = cw.jobs_with_runs(BOARD_PAGE_RUNS)
|
|
202
|
+
now = cw.now
|
|
159
203
|
runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
|
|
160
|
-
return html(HTML.dashboard_page(entries.map(&:job), runs_by_job,
|
|
204
|
+
return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, now, base, nil, board_lanes(cw, entries, now)))
|
|
161
205
|
end
|
|
162
206
|
if method == "GET" && parts[0] == "jobs" && parts.length == 2
|
|
163
207
|
job = cw.job_summary(parts[1])
|
|
164
208
|
return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
|
|
165
209
|
|
|
166
|
-
|
|
210
|
+
now = cw.now
|
|
211
|
+
# Enough runs to draw the job's week; the page lists the newest fifty.
|
|
212
|
+
limit = Timeline.week_runs_limit(job, now)
|
|
213
|
+
runs = cw.runs(job.name, limit)
|
|
214
|
+
return html(HTML.job_page(job, runs, now, base, runs.length < limit))
|
|
167
215
|
end
|
|
168
216
|
if method == "POST" && path == "/check"
|
|
169
217
|
cw.check
|
|
@@ -246,6 +294,22 @@ module Cronwatch
|
|
|
246
294
|
api({ ok: false, error: "Not found" }, 404)
|
|
247
295
|
end
|
|
248
296
|
|
|
297
|
+
# The board's timeline lanes, the first Timeline::BOARD_LANES jobs. The
|
|
298
|
+
# runs already read for the table usually cover the last day; only a job
|
|
299
|
+
# whose twenty newest runs all fall inside it (one that runs more often
|
|
300
|
+
# than every hour or so) is read again, deeper.
|
|
301
|
+
def board_lanes(cw, entries, now)
|
|
302
|
+
from = now - Timeline::BOARD_BEHIND_MS
|
|
303
|
+
entries.first(Timeline::BOARD_LANES).map do |entry|
|
|
304
|
+
runs = entry.runs
|
|
305
|
+
short = runs.length >= BOARD_PAGE_RUNS && runs.last.started_at > from
|
|
306
|
+
next Timeline::LaneInput.new(job: entry.job, runs: runs, complete: true) unless short
|
|
307
|
+
|
|
308
|
+
deeper = cw.runs(entry.job.name, Timeline::BOARD_RUNS)
|
|
309
|
+
Timeline::LaneInput.new(job: entry.job, runs: deeper, complete: deeper.length < Timeline::BOARD_RUNS)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
249
313
|
# Prints the development sign-in link, once per app.
|
|
250
314
|
def announce(request, base)
|
|
251
315
|
first = @announce_lock.synchronize do
|
|
@@ -255,7 +319,7 @@ module Cronwatch
|
|
|
255
319
|
end
|
|
256
320
|
return unless first
|
|
257
321
|
|
|
258
|
-
$stdout.puts(Web.development_sign_in_line(request
|
|
322
|
+
$stdout.puts(Web.development_sign_in_line(public_origin(request), base, @token))
|
|
259
323
|
$stdout.flush
|
|
260
324
|
end
|
|
261
325
|
|
|
@@ -295,7 +359,7 @@ module Cronwatch
|
|
|
295
359
|
# a page cannot forge either. Non-browser clients send neither.
|
|
296
360
|
def cross_site?(request)
|
|
297
361
|
origin = request.header("origin")
|
|
298
|
-
return true if !origin.nil? && origin != request
|
|
362
|
+
return true if !origin.nil? && origin != public_origin(request)
|
|
299
363
|
|
|
300
364
|
site = request.header("sec-fetch-site")
|
|
301
365
|
!site.nil? && site != "same-origin" && site != "none"
|
|
@@ -378,7 +442,12 @@ module Cronwatch
|
|
|
378
442
|
|
|
379
443
|
def redirect_back(request, base)
|
|
380
444
|
referer = request.header("referer") || ""
|
|
381
|
-
redirect(referer.start_with?("#{request
|
|
445
|
+
redirect(referer.start_with?("#{public_origin(request)}/") ? referer : "#{base}/")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# The origin a browser sees: the `origin` option when set, otherwise the request's own.
|
|
449
|
+
def public_origin(request)
|
|
450
|
+
@origin || request.origin
|
|
382
451
|
end
|
|
383
452
|
|
|
384
453
|
def api(body, status = 200, headers = {})
|
|
@@ -393,9 +462,19 @@ module Cronwatch
|
|
|
393
462
|
[303, { "location" => location, "cache-control" => "no-store", **SECURITY_HEADERS, **headers }, []]
|
|
394
463
|
end
|
|
395
464
|
|
|
396
|
-
|
|
465
|
+
# An app shell file. The worker may be scoped to the base (it is served
|
|
466
|
+
# from there anyway); the SVGs get a CSP of their own.
|
|
467
|
+
def shell(asset, base)
|
|
468
|
+
headers = { "content-type" => asset.type, "cache-control" => asset.cache, **SECURITY_HEADERS }
|
|
469
|
+
headers["content-security-policy"] = ASSET_CSP if asset.type == "image/svg+xml"
|
|
470
|
+
headers["service-worker-allowed"] = "#{base}/" if asset.worker
|
|
471
|
+
headers["content-length"] = asset.body.bytesize.to_s
|
|
472
|
+
[200, headers, [asset.body]]
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def html(body, status = 200, cache = "no-store")
|
|
397
476
|
[status, {
|
|
398
|
-
"content-type" => "text/html; charset=utf-8", "cache-control" =>
|
|
477
|
+
"content-type" => "text/html; charset=utf-8", "cache-control" => cache,
|
|
399
478
|
"content-security-policy" => CSP, "x-frame-options" => "DENY", "content-length" => body.bytesize.to_s,
|
|
400
479
|
**SECURITY_HEADERS,
|
|
401
480
|
}, [body]]
|
|
@@ -426,13 +505,11 @@ module Cronwatch
|
|
|
426
505
|
@env[key]
|
|
427
506
|
end
|
|
428
507
|
|
|
429
|
-
# scheme://host[:port], the page's origin
|
|
508
|
+
# scheme://host[:port], the page's origin, lowercased as a browser
|
|
509
|
+
# writes it in Origin and Referer (Rack's base_url keeps the case of
|
|
510
|
+
# the Host or X-Forwarded-Host it read).
|
|
430
511
|
def origin
|
|
431
|
-
@rack.base_url
|
|
432
|
-
end
|
|
433
|
-
|
|
434
|
-
def https?
|
|
435
|
-
@rack.scheme == "https"
|
|
512
|
+
@rack.base_url.downcase
|
|
436
513
|
end
|
|
437
514
|
|
|
438
515
|
# URLSearchParams#get: the first value, or nil.
|