cronwatch 0.12.5 → 0.13.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 +3 -3
- data/lib/cronwatch/alerts/discord.rb +1 -1
- data/lib/cronwatch/alerts/provider.rb +18 -0
- data/lib/cronwatch/alerts/slack.rb +1 -1
- data/lib/cronwatch/alerts/webhook.rb +8 -1
- data/lib/cronwatch/client.rb +143 -36
- data/lib/cronwatch/environment.rb +32 -21
- data/lib/cronwatch/evaluate.rb +67 -6
- data/lib/cronwatch/http.rb +17 -6
- data/lib/cronwatch/monitored.rb +3 -1
- data/lib/cronwatch/sidekiq.rb +3 -1
- data/lib/cronwatch/types.rb +8 -4
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +143 -28
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e036977146b656ead881d9668a920e8de3a42a9011284a20e21f839a46590ed
|
|
4
|
+
data.tar.gz: d0647d96854893d1bf7932d36ba5732ec50cec4a9b8427dc107e14fb8f11fcc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 34ea25633ee722da6ef7949f96b9e26856a4e53d800728308e47c5a07bc5cbef16dacc3a8ba3e771439339b77eed2bdec1af2a7e1b4a54361f8ca17f7d31b9d3
|
|
7
|
+
data.tar.gz: 37b1b660cf399b07f9dfdf3366cc5b43b2782b22ff70284cbd642463916f0de278c88ecb740c1ab4632547d609a6e05f56e2dcaaee6ef733bd55bf23bff107b1
|
data/README.md
CHANGED
|
@@ -117,7 +117,7 @@ class HardWorker
|
|
|
117
117
|
end
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
The same `cronwatch` as for ActiveJob, for classes that include `Sidekiq::Job` (or `Sidekiq::Worker`) directly. `Cronwatch::Sidekiq::ServerMiddleware` records each `perform` with the trigger `"sidekiq"` and raises the job's error on to Sidekiq, so retries and error handlers behave as before. In Rails the Railtie adds it to the Sidekiq server; elsewhere add it in `Sidekiq.configure_server` and call `Cronwatch::Sidekiq.ready!` once `Cronwatch.configure` has run. An ActiveJob class on Sidekiq's adapter keeps `Cronwatch::ActiveJob`; the middleware passes ActiveJob's wrapper through, so it is recorded once.
|
|
120
|
+
The same `cronwatch` as for ActiveJob, for classes that include `Sidekiq::Job` (or `Sidekiq::Worker`) directly. `Cronwatch::Sidekiq::ServerMiddleware` records each `perform` with the trigger `"sidekiq"` and raises the job's error on to Sidekiq, so retries and error handlers behave as before. In Rails the Railtie adds it to the Sidekiq server; elsewhere add it in `Sidekiq.configure_server` and call `Cronwatch::Sidekiq.ready!` once `Cronwatch.configure` has run. An ActiveJob class on Sidekiq's adapter keeps `Cronwatch::ActiveJob`; the middleware passes ActiveJob's wrapper through, so it is recorded once. A job stopped by Sidekiq's shutdown (`Sidekiq::Shutdown`, raised into a job still running when a deploy outlasts Sidekiq's timeout) goes back on the queue and runs again after the restart, so its run is recorded as failed (`Interrupted: Sidekiq::Shutdown`) but not judged: it opens no failed alert and does not count toward `failures_before_alert`. The same holds for an ActiveJob class on Sidekiq's adapter.
|
|
121
121
|
|
|
122
122
|
### Schedules from the scheduler's config
|
|
123
123
|
|
|
@@ -169,7 +169,7 @@ Rails.application.routes.draw do
|
|
|
169
169
|
end
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
-
Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while the environment (the first of `CRONWATCH_ENV`, `APP_ENV`, then `Rails.env` (when Rails is loaded), `RAILS_ENV`, or `RACK_ENV` that holds more than spaces, trimmed and lowercased) is `development`, `dev`, `local
|
|
172
|
+
Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. The token and `CRON_SECRET` are trimmed of the whitespace around them, so one read from a file with a trailing newline still works. Without a token, while the environment (the first of `CRONWATCH_ENV`, `APP_ENV`, then `Rails.env` (when Rails is loaded), `RAILS_ENV`, or `RACK_ENV` that holds more than spaces, trimmed and lowercased) is `development`, `dev`, or `local` (not `test` or `testing`: a test run or CI needs a token, as production does), it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. Puma, Unicorn, Thin, and rackup set `RACK_ENV` to `development` when nothing names an environment, production included, so under one of them `RACK_ENV=development` alone does not count: `CRONWATCH_ENV`, `APP_ENV`, `Rails.env`, or `RAILS_ENV` must say development (or `RACK_ENV` say `dev` or `local`). To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers, and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is the request's own scheme and `Host`. Behind a proxy, pass `trust_proxy: true` to read the first `X-Forwarded-Proto` and `X-Forwarded-Host` instead (only when the proxy sets or overwrites both, since a client can send them too), or `origin: "https://app.example.com"` to pin it. Under Rails `trust_proxy` defaults to true, as Rails itself trusts those headers; anywhere else it defaults to false, as in the SDK. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
|
|
173
173
|
|
|
174
174
|
There is no `handler()` as in the TypeScript SDK: for a job triggered over HTTP, wrap the controller action's body in `CW.job(...).run` (declared once, at boot) and check the bearer in the controller.
|
|
175
175
|
|
|
@@ -180,7 +180,7 @@ In any other Rack app:
|
|
|
180
180
|
map("/cronwatch") { run CW.routes }
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
-
`client.routes(**options)` (`token:`, `base_path:`, `origin:`) is the one way to build the dashboard, as `routes()` is in every language.
|
|
183
|
+
`client.routes(**options)` (`token:`, `base_path:`, `origin:`, `trust_proxy:`) is the one way to build the dashboard, as `routes()` is in every language.
|
|
184
184
|
|
|
185
185
|
### Public and internal
|
|
186
186
|
|
|
@@ -40,7 +40,7 @@ module Cronwatch
|
|
|
40
40
|
"embeds" => [embed],
|
|
41
41
|
}
|
|
42
42
|
response = @http.post(@webhook_url, JS.json(payload), { "content-type" => "application/json" })
|
|
43
|
-
raise "Discord webhook answered #{response.status}: #{
|
|
43
|
+
raise "Discord webhook answered #{response.status}: #{Provider.webhook_error_body(response, @webhook_url)}" unless response.ok?
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# The message in a code block, then the triage. Each part has its own
|
|
@@ -59,6 +59,24 @@ module Cronwatch
|
|
|
59
59
|
JS.head16(head, ERROR_BODY_MAX)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# The parts of a webhook URL that are its credential, to cut from an
|
|
63
|
+
# error body that echoes the request: its path and query together, then
|
|
64
|
+
# the last segment of its path (Slack's and Discord's token) on its own.
|
|
65
|
+
def url_secrets(url)
|
|
66
|
+
uri = URI.parse(url.to_s)
|
|
67
|
+
path = uri.path.nil? || uri.path.empty? ? "/" : uri.path
|
|
68
|
+
last = path.split("/", -1).last.to_s
|
|
69
|
+
["#{path}#{"?#{uri.query}" if uri.query && !uri.query.empty?}", last].reject { |part| part.empty? || part == "/" }
|
|
70
|
+
rescue URI::Error
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The start of a webhook's error answer, with the URL's credential cut
|
|
75
|
+
# out in case a proxy echoes the request: Slack's and Discord's errors.
|
|
76
|
+
def webhook_error_body(response, url)
|
|
77
|
+
error_body(Output.utf8(response.body.to_s).delete_prefix("\u{FEFF}"), url_secrets(url))
|
|
78
|
+
end
|
|
79
|
+
|
|
62
80
|
# A credential with the spaces and newlines a paste leaves around it
|
|
63
81
|
# taken off, as it goes in a header. Anything not a String is "".
|
|
64
82
|
def trimmed(value)
|
|
@@ -42,7 +42,7 @@ module Cronwatch
|
|
|
42
42
|
"blocks" => blocks,
|
|
43
43
|
}
|
|
44
44
|
response = @http.post(@webhook_url, JS.json(payload), { "content-type" => "application/json" })
|
|
45
|
-
raise "Slack webhook answered #{response.status}: #{
|
|
45
|
+
raise "Slack webhook answered #{response.status}: #{Provider.webhook_error_body(response, @webhook_url)}" unless response.ok?
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# Slack's three control characters. Escaping < and > also stops <!channel> and <url|links>.
|
|
@@ -37,8 +37,15 @@ module Cronwatch
|
|
|
37
37
|
OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, body.to_s)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
# `context` is the client's channel context: an empty-string secret (an
|
|
41
|
+
# unset variable read as "", say) signs nothing, and each send reports
|
|
42
|
+
# that through its on_error.
|
|
43
|
+
def call(alert, context = nil)
|
|
41
44
|
body = JS.json({ "schema" => SCHEMA }.merge(alert.to_h))
|
|
45
|
+
if @secret == "" && context.respond_to?(:on_error)
|
|
46
|
+
context.on_error(RuntimeError.new("webhook: secret is an empty string, so this request is sent unsigned; " \
|
|
47
|
+
"set the secret, or leave it out to send unsigned on purpose"))
|
|
48
|
+
end
|
|
42
49
|
headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }
|
|
43
50
|
# A pasted Authorization value often carries a stray space or newline, which a header would refuse.
|
|
44
51
|
@headers.each { |name, value| headers[name.to_s] = value.is_a?(String) ? JS.trim(value) : value }
|
data/lib/cronwatch/client.rb
CHANGED
|
@@ -81,8 +81,8 @@ module Cronwatch
|
|
|
81
81
|
# alerts: where alerts go: objects with #call(alert) and #name. Defaults to the console.
|
|
82
82
|
# triage: a callable taking a TriageContext and returning a short diagnosis, added to every alert but recoveries.
|
|
83
83
|
# cron_secret: a second bearer Cronwatch::Web accepts for /api/check, for an outside cron. Defaults to
|
|
84
|
-
# ENV["CRON_SECRET"]; an empty string or one of
|
|
85
|
-
# variable, counts as unset. Pass nil for none. Anything but a String or nil (false, a
|
|
84
|
+
# ENV["CRON_SECRET"], trimmed of the whitespace around it; an empty string or one of
|
|
85
|
+
# only whitespace, given here or in the variable, counts as unset. Pass nil for none. Anything but a String or nil (false, a
|
|
86
86
|
# number, a Symbol) raises TypeError.
|
|
87
87
|
# retention: how long finished runs are kept. Default "30d".
|
|
88
88
|
# defaults: grace, timeout, timezone, and failures_before_alert applied to every job unless it sets its own.
|
|
@@ -137,6 +137,9 @@ module Cronwatch
|
|
|
137
137
|
@clock = now || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
|
|
138
138
|
@on_error = on_error || method(:default_on_error)
|
|
139
139
|
@definitions = {}
|
|
140
|
+
# Declarations forgotten here (forget), kept so that run and resume_run
|
|
141
|
+
# by name declare the job again as it was written, not bare.
|
|
142
|
+
@forgotten = {}
|
|
140
143
|
@synced = Set.new
|
|
141
144
|
@registry = Mutex.new
|
|
142
145
|
@ready = false
|
|
@@ -166,11 +169,29 @@ module Cronwatch
|
|
|
166
169
|
validate_definition(definition)
|
|
167
170
|
@registry.synchronize do
|
|
168
171
|
@definitions[name] = definition
|
|
172
|
+
@forgotten.delete(name)
|
|
169
173
|
@synced.delete(name)
|
|
170
174
|
end
|
|
171
175
|
JobHandle.new(self, definition)
|
|
172
176
|
end
|
|
173
177
|
|
|
178
|
+
# The declaration of `name`: the standing one, else one forgotten here,
|
|
179
|
+
# declared again as it was.
|
|
180
|
+
def declared(name)
|
|
181
|
+
@registry.synchronize do
|
|
182
|
+
standing = @definitions[name]
|
|
183
|
+
next standing if standing
|
|
184
|
+
|
|
185
|
+
forgotten = @forgotten.delete(name)
|
|
186
|
+
if forgotten
|
|
187
|
+
@definitions[name] = forgotten
|
|
188
|
+
@synced.delete(name)
|
|
189
|
+
end
|
|
190
|
+
forgotten
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private :declared
|
|
194
|
+
|
|
174
195
|
# Run a job by name without keeping a handle, declaring it on first use
|
|
175
196
|
# (or again, when options are given).
|
|
176
197
|
#
|
|
@@ -184,8 +205,8 @@ module Cronwatch
|
|
|
184
205
|
return get_run(name_or_id)
|
|
185
206
|
end
|
|
186
207
|
|
|
187
|
-
|
|
188
|
-
handle =
|
|
208
|
+
definition = options.any? ? nil : declared(name_or_id.to_s)
|
|
209
|
+
handle = definition.nil? ? job(name_or_id, **options) : JobHandle.new(self, definition)
|
|
189
210
|
handle.run(&block)
|
|
190
211
|
end
|
|
191
212
|
|
|
@@ -205,8 +226,14 @@ module Cronwatch
|
|
|
205
226
|
# error message, or nil when the result is fine: an HTTP handler uses it to
|
|
206
227
|
# count a 500 response as a failed run.
|
|
207
228
|
#
|
|
229
|
+
# `requeued_on_shutdown` is for a queue that puts a job back when its
|
|
230
|
+
# worker shuts down (Sidekiq raises Sidekiq::Shutdown into the job and
|
|
231
|
+
# runs it again after the restart): a run stopped that way is recorded as
|
|
232
|
+
# failed but not judged, so a deploy opens no failed alert and does not
|
|
233
|
+
# count toward failures_before_alert.
|
|
234
|
+
#
|
|
208
235
|
# @api private For integrations (JobHandle#run, ActiveJob, Sidekiq), not apps.
|
|
209
|
-
def execute(definition, trigger, failure: nil)
|
|
236
|
+
def execute(definition, trigger, failure: nil, requeued_on_shutdown: false)
|
|
210
237
|
after_fork_check
|
|
211
238
|
name = definition.name
|
|
212
239
|
started_at = now
|
|
@@ -224,7 +251,7 @@ module Cronwatch
|
|
|
224
251
|
# just before the block runs. The result is the same.
|
|
225
252
|
if recorded
|
|
226
253
|
begin
|
|
227
|
-
update_state(name) { |before| [Evaluate.on_run_start(before), nil] }
|
|
254
|
+
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
228
255
|
rescue StandardError => e
|
|
229
256
|
report(e, "starting #{name}")
|
|
230
257
|
end
|
|
@@ -252,7 +279,8 @@ module Cronwatch
|
|
|
252
279
|
|
|
253
280
|
conclude(definition, run, result, error, threw, recorder.expect_text || returned, failure: failure)
|
|
254
281
|
begin
|
|
255
|
-
|
|
282
|
+
judge = !(requeued_on_shutdown && threw && Client.shutdown?(error))
|
|
283
|
+
ignored = record_finish(definition, run, recorded, finished_at, judge: judge)
|
|
256
284
|
report(RuntimeError.new("run #{run.id} of #{name} #{ignored}; ignored"), "finishing #{name}") if ignored
|
|
257
285
|
rescue StandardError => e
|
|
258
286
|
report(e, "recording #{name}")
|
|
@@ -267,7 +295,7 @@ module Cronwatch
|
|
|
267
295
|
# job must be declared in this process, or it raises ArgumentError.
|
|
268
296
|
def resume_run(name, run_id)
|
|
269
297
|
name = name.to_s if name.is_a?(Symbol)
|
|
270
|
-
definition =
|
|
298
|
+
definition = declared(name)
|
|
271
299
|
raise ArgumentError, "resume_run: job \"#{name}\" is not declared; call job first" unless definition
|
|
272
300
|
|
|
273
301
|
resume_handle(definition, run_id)
|
|
@@ -397,7 +425,7 @@ module Cronwatch
|
|
|
397
425
|
end
|
|
398
426
|
return [] unless evaluate
|
|
399
427
|
|
|
400
|
-
update_state(run.job) { |before| [Evaluate.on_run_start(before), nil] }
|
|
428
|
+
update_state(run.job) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
401
429
|
return [] if run.status == :running
|
|
402
430
|
|
|
403
431
|
finish_run(definition, run, now)
|
|
@@ -496,13 +524,15 @@ module Cronwatch
|
|
|
496
524
|
end
|
|
497
525
|
|
|
498
526
|
# Remove a job and its runs from the store. A job still declared in code
|
|
499
|
-
# comes back: here on its next run,
|
|
500
|
-
#
|
|
501
|
-
#
|
|
527
|
+
# comes back, with no history: here on its next run, declared as it was
|
|
528
|
+
# written (run and resume_run by name included), and in any other process
|
|
529
|
+
# that declares it on its next run there, or at that process's next check
|
|
530
|
+
# or dashboard read.
|
|
502
531
|
def forget(name)
|
|
503
532
|
ensure_ready
|
|
504
533
|
@registry.synchronize do
|
|
505
|
-
@definitions.delete(name)
|
|
534
|
+
definition = @definitions.delete(name)
|
|
535
|
+
@forgotten[name] = definition if definition
|
|
506
536
|
@synced.delete(name)
|
|
507
537
|
end
|
|
508
538
|
@store.delete_job(name)
|
|
@@ -886,11 +916,24 @@ module Cronwatch
|
|
|
886
916
|
report(RuntimeError.new("run #{run.id} of #{run.job} #{ignored}; ignored"), "recording #{run.job}")
|
|
887
917
|
return []
|
|
888
918
|
end
|
|
889
|
-
|
|
919
|
+
unless evaluate
|
|
920
|
+
# Stored unjudged on purpose: a check must not evaluate it as one whose evaluation was lost.
|
|
921
|
+
release_unjudged(run)
|
|
922
|
+
return []
|
|
923
|
+
end
|
|
924
|
+
return [] if late && run.status != :ok
|
|
890
925
|
|
|
891
926
|
finish_run(definition, run, now)
|
|
892
927
|
end
|
|
893
928
|
|
|
929
|
+
# Takes a run stored without judging out of the job's pending runs, so a
|
|
930
|
+
# check does not evaluate it as one whose evaluation was lost.
|
|
931
|
+
def release_unjudged(run)
|
|
932
|
+
update_state(run.job) { |current| [Evaluate.release_run(current, run.id), nil] }
|
|
933
|
+
rescue StandardError => e
|
|
934
|
+
report(e, "recording #{run.job}")
|
|
935
|
+
end
|
|
936
|
+
|
|
894
937
|
# A conditional write (the store's update_run_if), or for a store
|
|
895
938
|
# without one, a read then a plain write, which is safe only while one
|
|
896
939
|
# process at a time finishes a given run.
|
|
@@ -926,7 +969,7 @@ module Cronwatch
|
|
|
926
969
|
if threw
|
|
927
970
|
run.status = :failed
|
|
928
971
|
run.error = Output.describe_error(error)
|
|
929
|
-
elsif (problem = failure
|
|
972
|
+
elsif (problem = failure_of(failure, result))
|
|
930
973
|
run.status = :failed
|
|
931
974
|
run.error = Output.utf8(problem.to_s)
|
|
932
975
|
else
|
|
@@ -945,18 +988,26 @@ module Cronwatch
|
|
|
945
988
|
run.error = Output.redact_and_cap(run.error, @redact) unless run.error.nil?
|
|
946
989
|
end
|
|
947
990
|
|
|
991
|
+
# What the failure: callable makes of the result, or nil. One that raises
|
|
992
|
+
# fails the run with its error described, so the run is still recorded.
|
|
993
|
+
def failure_of(failure, result)
|
|
994
|
+
failure&.call(result)
|
|
995
|
+
rescue StandardError => e
|
|
996
|
+
Output.describe_error(e)
|
|
997
|
+
end
|
|
998
|
+
|
|
948
999
|
# Writes a finished run and evaluates it. `recorded` says whether its
|
|
949
1000
|
# start was written; if not, it is inserted now. Returns why nothing was
|
|
950
1001
|
# recorded (another process finished the run first, say), or nil. Raises
|
|
951
1002
|
# when the store does, so a handle can be finished again. Shared by
|
|
952
1003
|
# execute and RunHandle#finish.
|
|
953
|
-
def record_finish(definition, run, recorded, finished_at)
|
|
1004
|
+
def record_finish(definition, run, recorded, finished_at, judge: true)
|
|
954
1005
|
unless recorded
|
|
955
1006
|
# The start was never written; the store may be back by now.
|
|
956
1007
|
sync(definition)
|
|
957
1008
|
begin
|
|
958
1009
|
@store.insert_run(run)
|
|
959
|
-
finish_run(Serialize.to_stored(definition), run, finished_at)
|
|
1010
|
+
finish_run(Serialize.to_stored(definition), run, finished_at) if judge
|
|
960
1011
|
return nil
|
|
961
1012
|
rescue StandardError => e
|
|
962
1013
|
# Another process may have recorded a run with this id meanwhile.
|
|
@@ -972,10 +1023,22 @@ module Cronwatch
|
|
|
972
1023
|
late, ignored = claim_finish(run)
|
|
973
1024
|
return ignored if ignored
|
|
974
1025
|
|
|
975
|
-
|
|
1026
|
+
if !judge
|
|
1027
|
+
release_unjudged(run)
|
|
1028
|
+
elsif !late || run.status == :ok
|
|
1029
|
+
finish_run(Serialize.to_stored(definition), run, finished_at)
|
|
1030
|
+
end
|
|
976
1031
|
nil
|
|
977
1032
|
end
|
|
978
1033
|
|
|
1034
|
+
# A job's worker shutting down, which its queue answers by running the job
|
|
1035
|
+
# again: Sidekiq::Shutdown, when Sidekiq is loaded.
|
|
1036
|
+
#
|
|
1037
|
+
# @api private
|
|
1038
|
+
def self.shutdown?(error)
|
|
1039
|
+
defined?(::Sidekiq::Shutdown) && error.is_a?(::Sidekiq::Shutdown) ? true : false
|
|
1040
|
+
end
|
|
1041
|
+
|
|
979
1042
|
# The start of execute without the block: the run is inserted and missed
|
|
980
1043
|
# and stuck close (on_run_start). A store that fails is reported and the
|
|
981
1044
|
# handle inserts the finished run instead, as execute does.
|
|
@@ -1011,7 +1074,7 @@ module Cronwatch
|
|
|
1011
1074
|
end
|
|
1012
1075
|
if recorded
|
|
1013
1076
|
begin
|
|
1014
|
-
update_state(name) { |before| [Evaluate.on_run_start(before), nil] }
|
|
1077
|
+
update_state(name) { |before| [Evaluate.hold_run(Evaluate.on_run_start(before), run.id), nil] }
|
|
1015
1078
|
rescue StandardError => e
|
|
1016
1079
|
report(e, "starting #{name}")
|
|
1017
1080
|
end
|
|
@@ -1162,6 +1225,30 @@ module Cronwatch
|
|
|
1162
1225
|
joined && Output.cap(joined)
|
|
1163
1226
|
end
|
|
1164
1227
|
|
|
1228
|
+
# Runs the job's state still holds as pending (Evaluate.hold_run) whose
|
|
1229
|
+
# finish was written but never evaluated: the process that finished them
|
|
1230
|
+
# stopped, or its state write failed, between the two writes. Each one
|
|
1231
|
+
# finished more than SEND_LEASE_MS ago is evaluated now, as its finish
|
|
1232
|
+
# would have been; one still running is left to the stuck check, and one
|
|
1233
|
+
# the store no longer has (given back, or pruned) is let go. The wait
|
|
1234
|
+
# leaves a finish under way in another process to write its own evaluation.
|
|
1235
|
+
def evaluate_pending(stored, recent, state, at)
|
|
1236
|
+
alerts = []
|
|
1237
|
+
(state.pending_runs || []).each do |id|
|
|
1238
|
+
run = recent.find { |r| r.id == id } || @store.get_run(id)
|
|
1239
|
+
if run.nil? || run.job != stored.name
|
|
1240
|
+
update_state(stored.name) { |current| [Evaluate.release_run(current, id), nil] }
|
|
1241
|
+
next
|
|
1242
|
+
end
|
|
1243
|
+
next if run.status == :running || !run.finished_at.is_a?(Numeric) || run.finished_at > at - Evaluate::SEND_LEASE_MS
|
|
1244
|
+
|
|
1245
|
+
alerts.concat(finish_run(stored.definition, run, at))
|
|
1246
|
+
rescue StandardError => e
|
|
1247
|
+
report(e, "evaluating run #{id} of #{stored.name}")
|
|
1248
|
+
end
|
|
1249
|
+
alerts
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1165
1252
|
# Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
1166
1253
|
# written, against the job's state and send what that produces. The
|
|
1167
1254
|
# alerts are written with that state (see outbox). Never raises.
|
|
@@ -1171,7 +1258,8 @@ module Cronwatch
|
|
|
1171
1258
|
past = nil
|
|
1172
1259
|
_, held = update_state(run.job) do |previous|
|
|
1173
1260
|
past ||= history(run)
|
|
1174
|
-
|
|
1261
|
+
before = Evaluate.release_run(previous, run.id)
|
|
1262
|
+
outbox(Evaluate.apply_silence(before, Evaluate.on_run_finish(definition, run, before, past, at), at), definition, at)
|
|
1175
1263
|
end
|
|
1176
1264
|
rescue StandardError => e
|
|
1177
1265
|
report(e, "evaluating #{run.job}")
|
|
@@ -1280,6 +1368,7 @@ module Cronwatch
|
|
|
1280
1368
|
report_dropped(stored.name, held.dropped)
|
|
1281
1369
|
alerts.concat(retry_undelivered(stored.name, state, at, retries))
|
|
1282
1370
|
alerts.concat(dispatch(stored.name, held.alerts, at))
|
|
1371
|
+
alerts.concat(evaluate_pending(stored, recent, state, at))
|
|
1283
1372
|
jobs << Evaluate.summarize(stored, recent, state, next_expected_at, at)
|
|
1284
1373
|
rescue StandardError => e
|
|
1285
1374
|
report(e, "checking #{stored.name}")
|
|
@@ -1376,22 +1465,36 @@ module Cronwatch
|
|
|
1376
1465
|
RetryBudget = Struct.new(:spent_ms)
|
|
1377
1466
|
|
|
1378
1467
|
# Send the alerts that no channel accepted last time, once each, oldest
|
|
1379
|
-
# first.
|
|
1380
|
-
#
|
|
1381
|
-
#
|
|
1382
|
-
#
|
|
1468
|
+
# first. They are claimed first (Evaluate.claim_undelivered): moved into
|
|
1469
|
+
# `sending` with a lease, in one conditional write, so when several
|
|
1470
|
+
# processes check at once only the one whose write lands sends them. An
|
|
1471
|
+
# alert that no longer describes the job (Evaluate.stale_alert?) is
|
|
1472
|
+
# dropped instead. Retries across a check share RETRY_BUDGET_MS of
|
|
1473
|
+
# wall-clock time; claimed alerts it has no time left for go back to the
|
|
1474
|
+
# queue for the next check.
|
|
1383
1475
|
def retry_undelivered(name, state, at, budget)
|
|
1384
|
-
|
|
1385
|
-
|
|
1476
|
+
return [] if (state.undelivered || []).empty? || @defer_delivery
|
|
1477
|
+
|
|
1478
|
+
begin
|
|
1479
|
+
_, claimed = update_state(name) do |previous|
|
|
1480
|
+
claim = Evaluate.claim_undelivered(previous, at, now + Evaluate::SEND_LEASE_MS)
|
|
1481
|
+
[claim.state, claim.claimed]
|
|
1482
|
+
end
|
|
1483
|
+
rescue StandardError => e
|
|
1484
|
+
report(e, "claiming undelivered alerts for #{name}")
|
|
1485
|
+
return []
|
|
1486
|
+
end
|
|
1487
|
+
return [] if claimed.empty?
|
|
1386
1488
|
|
|
1387
1489
|
delivered = []
|
|
1388
1490
|
failed = []
|
|
1389
|
-
|
|
1390
|
-
pending.each do |alert|
|
|
1391
|
-
next if dropped.any? { |d| d.equal?(alert) }
|
|
1392
|
-
|
|
1491
|
+
claimed.each do |alert|
|
|
1393
1492
|
left = @retry_budget_ms - budget.spent_ms
|
|
1394
|
-
|
|
1493
|
+
if left <= 0
|
|
1494
|
+
# Not tried: back to the queue as it was.
|
|
1495
|
+
failed << alert
|
|
1496
|
+
next
|
|
1497
|
+
end
|
|
1395
1498
|
|
|
1396
1499
|
started = AbortSignal.monotonic
|
|
1397
1500
|
# An alert queued by a process that delivers at check time was never
|
|
@@ -1400,7 +1503,7 @@ module Cronwatch
|
|
|
1400
1503
|
(deliver(alert) ? delivered : failed) << alert
|
|
1401
1504
|
budget.spent_ms += [0, ((AbortSignal.monotonic - started) * 1000).round].max
|
|
1402
1505
|
end
|
|
1403
|
-
record_delivery(name, delivered, failed,
|
|
1506
|
+
record_delivery(name, delivered, failed, [], at)
|
|
1404
1507
|
delivered
|
|
1405
1508
|
end
|
|
1406
1509
|
|
|
@@ -1493,12 +1596,16 @@ module Cronwatch
|
|
|
1493
1596
|
|
|
1494
1597
|
# Sets the alert's triage to the diagnosis, or to nil (JSON null) when
|
|
1495
1598
|
# there is none (it raised, timed out, or answered nil or ""), so it is
|
|
1496
|
-
# tried once per alert. While a triage that timed out
|
|
1497
|
-
# alerts go out without one rather than start another
|
|
1599
|
+
# tried once per alert. While a triage that timed out on its full timeout
|
|
1600
|
+
# is still going, alerts go out without one rather than start another
|
|
1601
|
+
# beside it, and stay untried so a later retry can triage them. A timeout
|
|
1602
|
+
# cut short by the retry budget says nothing about Claude, so it does not
|
|
1603
|
+
# hold back the triages after it.
|
|
1498
1604
|
def add_triage(alert, timeout_ms)
|
|
1499
1605
|
signal = AbortSignal.new
|
|
1500
1606
|
if @sending_lock.synchronize { @abandoned[:triage]&.alive? }
|
|
1501
|
-
|
|
1607
|
+
report(TimeoutError.new("skipped: an earlier triage timed out and is still running"), "triage for #{alert.job}")
|
|
1608
|
+
return
|
|
1502
1609
|
end
|
|
1503
1610
|
|
|
1504
1611
|
recent = @store.list_runs(alert.job, 5)
|
|
@@ -1512,7 +1619,7 @@ module Cronwatch
|
|
|
1512
1619
|
outcome = [:error, e]
|
|
1513
1620
|
end
|
|
1514
1621
|
unless thread.join(timeout_ms / 1000.0)
|
|
1515
|
-
@sending_lock.synchronize { @abandoned[:triage] = thread }
|
|
1622
|
+
@sending_lock.synchronize { @abandoned[:triage] = thread } if timeout_ms >= @triage_timeout_ms
|
|
1516
1623
|
raise TimeoutError, "timed out after #{timeout_ms}ms"
|
|
1517
1624
|
end
|
|
1518
1625
|
outcome ||= [:error, RuntimeError.new("the triage thread ended without an answer")]
|
|
@@ -4,19 +4,17 @@ module Cronwatch
|
|
|
4
4
|
# The app's environment, read as every CronWatch library reads it: the
|
|
5
5
|
# first of CRONWATCH_ENV, APP_ENV, then the app's own (Rails.env when Rails
|
|
6
6
|
# is loaded, else RAILS_ENV, then RACK_ENV) that holds more than spaces,
|
|
7
|
-
# trimmed and lowercased, with "prod" read as "production" and "dev"
|
|
8
|
-
# "local"
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# trimmed and lowercased, with "prod" read as "production" and "dev" and
|
|
8
|
+
# "local" as "development". "test" and "testing" are not development: a
|
|
9
|
+
# test run or CI needs CRON_SECRET or a token, as production does. None set
|
|
10
|
+
# is neither, which is the safe reading. The SDK reads NODE_ENV where this
|
|
11
|
+
# reads the app's own.
|
|
11
12
|
#
|
|
12
13
|
# @api private
|
|
13
14
|
module Environment
|
|
14
15
|
# The variables read before the app's own, in order.
|
|
15
16
|
SHARED = %w[CRONWATCH_ENV APP_ENV].freeze
|
|
16
|
-
ALIASES = {
|
|
17
|
-
"prod" => "production", "dev" => "development", "local" => "development", "test" => "development",
|
|
18
|
-
"testing" => "development",
|
|
19
|
-
}.freeze
|
|
17
|
+
ALIASES = { "prod" => "production", "dev" => "development", "local" => "development" }.freeze
|
|
20
18
|
private_constant :SHARED, :ALIASES
|
|
21
19
|
|
|
22
20
|
module_function
|
|
@@ -59,20 +57,21 @@ module Cronwatch
|
|
|
59
57
|
JS.trim(value.dup.force_encoding(Encoding::UTF_8).scrub).empty?
|
|
60
58
|
end
|
|
61
59
|
|
|
62
|
-
# A secret from the environment (CRONWATCH_TOKEN, CRON_SECRET)
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
60
|
+
# A secret from the environment (CRONWATCH_TOKEN, CRON_SECRET), trimmed as
|
|
61
|
+
# JavaScript's String.prototype.trim trims (JS::WHITESPACE): a value read
|
|
62
|
+
# from a file often ends in a newline that no header can carry, since
|
|
63
|
+
# HTTP strips the whitespace around a header's value. nil when the
|
|
64
|
+
# variable is unset, empty, or only whitespace, so a blank value counts as
|
|
65
|
+
# not set and the routes fail closed.
|
|
66
66
|
def secret(variable)
|
|
67
|
-
|
|
68
|
-
present?(value) ? value : nil
|
|
67
|
+
trimmed(ENV.fetch(variable, nil))
|
|
69
68
|
end
|
|
70
69
|
|
|
71
|
-
# A token or secret passed in code: a String, or nil. A String
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
# password.
|
|
70
|
+
# A token or secret passed in code: a String, or nil. A String is
|
|
71
|
+
# trimmed, as secret trims, and one that is empty or only whitespace
|
|
72
|
+
# counts as not given (nil here; the caller tells that from nil the
|
|
73
|
+
# opt-out). Anything else (false, a number, a Symbol) raises TypeError
|
|
74
|
+
# naming the option, so it never becomes a password.
|
|
76
75
|
def secret_option(value, what)
|
|
77
76
|
return nil if value.nil?
|
|
78
77
|
unless value.is_a?(String)
|
|
@@ -80,7 +79,19 @@ module Cronwatch
|
|
|
80
79
|
raise TypeError, "#{what} must be a String, or nil to opt out, not #{kind}"
|
|
81
80
|
end
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
trimmed(value)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A secret trimmed as JavaScript trims, or nil when nothing is left. Bytes
|
|
86
|
+
# that are not UTF-8 are kept as they are.
|
|
87
|
+
def trimmed(value)
|
|
88
|
+
return nil unless present?(value)
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
JS.trim(value)
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
value
|
|
94
|
+
end
|
|
84
95
|
end
|
|
85
96
|
|
|
86
97
|
# The framework's own name for the environment, as it sets it: Rails.env
|
|
@@ -94,7 +105,7 @@ module Cronwatch
|
|
|
94
105
|
[ENV.fetch("RAILS_ENV", nil), ENV.fetch("RACK_ENV", nil)].find { |value| value && !value.empty? }
|
|
95
106
|
end
|
|
96
107
|
|
|
97
|
-
# Development (
|
|
108
|
+
# Development (not test): Cronwatch::Web without a token serves only then.
|
|
98
109
|
def development?
|
|
99
110
|
environment == "development"
|
|
100
111
|
end
|
data/lib/cronwatch/evaluate.rb
CHANGED
|
@@ -83,8 +83,9 @@ module Cronwatch
|
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
# A stored state with every field present, or a fresh one. State written
|
|
86
|
-
# by an older version lacks the newer fields. `sending` and
|
|
87
|
-
# are the exceptions: each is there only while it holds
|
|
86
|
+
# by an older version lacks the newer fields. `sending`, `under_floor`, and
|
|
87
|
+
# `pending_runs` are the exceptions: each is there only while it holds
|
|
88
|
+
# something.
|
|
88
89
|
#
|
|
89
90
|
# Read leniently, since a foreign, hand-edited, or damaged row must affect
|
|
90
91
|
# only its own job, and the next write puts it right: a state that is not
|
|
@@ -93,14 +94,15 @@ module Cronwatch
|
|
|
93
94
|
# `last_alert_at` that are not numbers read as nil; `pending_recovery`
|
|
94
95
|
# keeps only its conditions (strings as stored), and `undelivered` only
|
|
95
96
|
# its entries that are objects (a list of neither shape reads as []),
|
|
96
|
-
# and `under_floor` only
|
|
97
|
-
# are kept as written.
|
|
97
|
+
# and `under_floor` and `pending_runs` only their strings (each absent
|
|
98
|
+
# when none). Unknown fields are kept as written.
|
|
98
99
|
def normalize_state(state, job)
|
|
99
100
|
state = JobState.from_h(state)
|
|
100
101
|
return empty_state(job) if state.nil?
|
|
101
102
|
|
|
102
103
|
sending = state.sending
|
|
103
104
|
under_floor = state.under_floor.is_a?(Array) ? state.under_floor.grep(String) : []
|
|
105
|
+
pending_runs = state.pending_runs.is_a?(Array) ? state.pending_runs.grep(String) : []
|
|
104
106
|
open = state.open.is_a?(Hash) ? state.open.select { |_, at| json_number?(at) } : {}
|
|
105
107
|
pending = state.pending_recovery.is_a?(Array) ? state.pending_recovery.grep(Symbol) : []
|
|
106
108
|
undelivered = state.undelivered.is_a?(Array) ? state.undelivered.grep(Alert) : []
|
|
@@ -115,6 +117,7 @@ module Cronwatch
|
|
|
115
117
|
version: state.version,
|
|
116
118
|
sending: sending.is_a?(Array) && !sending.empty? ? sending.dup : nil,
|
|
117
119
|
under_floor: under_floor.empty? ? nil : under_floor,
|
|
120
|
+
pending_runs: pending_runs.empty? ? nil : pending_runs,
|
|
118
121
|
# Fields a newer release wrote, carried through every write.
|
|
119
122
|
extra: state.extra,
|
|
120
123
|
)
|
|
@@ -207,6 +210,61 @@ module Cronwatch
|
|
|
207
210
|
queue_undelivered(following, failed)
|
|
208
211
|
end
|
|
209
212
|
|
|
213
|
+
# A check's claim on the undelivered queue, written before it sends, so
|
|
214
|
+
# two processes checking at once never retry the same alert: the queued
|
|
215
|
+
# alerts that still describe the job move into `sending`, oldest first,
|
|
216
|
+
# each with its lease ending at `until_at`, as many as `sending` has room
|
|
217
|
+
# for under MAX_UNDELIVERED (the rest wait for the next check); stale
|
|
218
|
+
# ones (stale_alert?) leave the queue. While the job is silenced only
|
|
219
|
+
# recoveries are claimed. The process whose write lands sends `claimed`.
|
|
220
|
+
Claim = Struct.new(:state, :claimed, :stale, keyword_init: true) do
|
|
221
|
+
def to_h = { "state" => state.to_h, "claimed" => claimed.map(&:to_h), "stale" => stale.map(&:to_h) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def claim_undelivered(state, now, until_at)
|
|
225
|
+
following = clone_state(state)
|
|
226
|
+
queue = following.undelivered
|
|
227
|
+
stale = queue.select { |alert| stale_alert?(alert, following) }
|
|
228
|
+
silenced = silenced?(following, now)
|
|
229
|
+
room = [0, MAX_UNDELIVERED - (following.sending || []).length].max
|
|
230
|
+
fresh = queue.reject { |alert| stale.any? { |old| old.equal?(alert) } }
|
|
231
|
+
claimed = fresh.select { |alert| !silenced || alert.type == :recovered }.first(room)
|
|
232
|
+
return Claim.new(state: state, claimed: claimed, stale: stale) if stale.empty? && claimed.empty?
|
|
233
|
+
|
|
234
|
+
following.undelivered = fresh.reject { |alert| claimed.any? { |sent| sent.equal?(alert) } }
|
|
235
|
+
unless claimed.empty?
|
|
236
|
+
following.sending = (following.sending || []) + claimed.map { |alert| { "until" => until_at, "alert" => alert } }
|
|
237
|
+
end
|
|
238
|
+
Claim.new(state: following, claimed: claimed, stale: stale)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Runs held in `pending_runs` per job; past it the oldest go.
|
|
242
|
+
MAX_PENDING_RUNS = 100
|
|
243
|
+
|
|
244
|
+
# A run just started, held in `pending_runs` until its finish is
|
|
245
|
+
# evaluated (release_run), so a check can evaluate it if the process that
|
|
246
|
+
# finished it stops before writing the evaluation. Written with
|
|
247
|
+
# on_run_start's state. Only the newest MAX_PENDING_RUNS are kept.
|
|
248
|
+
def hold_run(state, run_id)
|
|
249
|
+
following = clone_state(state)
|
|
250
|
+
held = (following.pending_runs || []).reject { |id| id == run_id }
|
|
251
|
+
held << run_id
|
|
252
|
+
following.pending_runs = held.last(MAX_PENDING_RUNS)
|
|
253
|
+
following
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# A run taken out of `pending_runs`, as its evaluation is written or once
|
|
257
|
+
# it is gone from the store.
|
|
258
|
+
def release_run(state, run_id)
|
|
259
|
+
held = state.pending_runs.is_a?(Array) ? state.pending_runs : []
|
|
260
|
+
return state unless held.include?(run_id)
|
|
261
|
+
|
|
262
|
+
following = clone_state(state)
|
|
263
|
+
left = held.reject { |id| id == run_id }
|
|
264
|
+
following.pending_runs = left.empty? ? nil : left
|
|
265
|
+
following
|
|
266
|
+
end
|
|
267
|
+
|
|
210
268
|
# The alert of an entry of `sending`, or nil when it has none, or one
|
|
211
269
|
# that did not parse as an alert (which could not be sent either).
|
|
212
270
|
def sending_alert(entry)
|
|
@@ -504,11 +562,14 @@ module Cronwatch
|
|
|
504
562
|
end
|
|
505
563
|
|
|
506
564
|
# An evaluation as it is saved and sent: while the job was silenced when it
|
|
507
|
-
# began, nothing opens and
|
|
565
|
+
# began, nothing opens and no new alert is sent, but a recovery is. A
|
|
566
|
+
# condition can only close during a silence if it opened (and alerted)
|
|
567
|
+
# before it, so its recovery closes an incident someone was told about.
|
|
508
568
|
def apply_silence(previous, evaluation, now)
|
|
509
569
|
return evaluation unless silenced?(previous, now)
|
|
510
570
|
|
|
511
|
-
Evaluation.new(state: mute_opens(previous, evaluation.state),
|
|
571
|
+
Evaluation.new(state: mute_opens(previous, evaluation.state),
|
|
572
|
+
alerts: evaluation.alerts.select { |alert| alert.type == :recovered })
|
|
512
573
|
end
|
|
513
574
|
|
|
514
575
|
# Whether an alert waiting to be retried no longer describes the job, so it
|
data/lib/cronwatch/http.rb
CHANGED
|
@@ -49,6 +49,10 @@ module Cronwatch
|
|
|
49
49
|
headers.each { |k, v| request[k] = HTTP.trim_header(v) }
|
|
50
50
|
request.body = body
|
|
51
51
|
http = connection(uri)
|
|
52
|
+
# Net::HTTP opens the socket under Timeout.timeout(open_timeout), or
|
|
53
|
+
# TCPSocket's own open_timeout where Ruby has one, and both cover the
|
|
54
|
+
# name lookup: it can be interrupted from Ruby 3.3. On Ruby 3.2 a lookup
|
|
55
|
+
# that hangs is cut off only by the client's channel deadline.
|
|
52
56
|
http.open_timeout = HTTP.remaining(deadline)
|
|
53
57
|
http.start do |conn|
|
|
54
58
|
remaining = HTTP.remaining(deadline)
|
|
@@ -141,12 +145,19 @@ module Cronwatch
|
|
|
141
145
|
value.to_s.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, "")
|
|
142
146
|
end
|
|
143
147
|
|
|
144
|
-
# Compares
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
# Compares what a request `sent` with the `secret` without stopping at the
|
|
149
|
+
# first differing byte, and in time that depends only on the length of
|
|
150
|
+
# what was sent, so neither the secret's bytes nor its length can be
|
|
151
|
+
# timed. Every byte of `sent` is compared, against the secret's byte at
|
|
152
|
+
# the same place (repeating it past its end), and a length that differs
|
|
153
|
+
# counts as a difference. The SDK's constantTimeEqual, over bytes.
|
|
154
|
+
def constant_time_equal?(sent, secret)
|
|
155
|
+
expected = secret.b
|
|
156
|
+
return sent.bytesize.zero? if expected.empty?
|
|
157
|
+
|
|
158
|
+
diff = sent.bytesize ^ expected.bytesize
|
|
159
|
+
size = expected.bytesize
|
|
160
|
+
sent.b.each_byte.with_index { |byte, i| diff |= byte ^ expected.getbyte(i % size) }
|
|
150
161
|
diff.zero?
|
|
151
162
|
end
|
|
152
163
|
end
|
data/lib/cronwatch/monitored.rb
CHANGED
|
@@ -166,7 +166,9 @@ module Cronwatch
|
|
|
166
166
|
return yield unless handle
|
|
167
167
|
|
|
168
168
|
holder = job.respond_to?(:cronwatch_with, true) ? job : Discard
|
|
169
|
-
|
|
169
|
+
# Sidekiq puts a job stopped by its shutdown back on the queue, so that
|
|
170
|
+
# run is recorded but not judged (see Client#execute).
|
|
171
|
+
outcome = client.execute(handle.definition, trigger, requeued_on_shutdown: true) do |context|
|
|
170
172
|
holder.__send__(:cronwatch_with, context) { yield }
|
|
171
173
|
end
|
|
172
174
|
raise outcome.error if outcome.threw
|
data/lib/cronwatch/sidekiq.rb
CHANGED
|
@@ -30,7 +30,9 @@ module Cronwatch
|
|
|
30
30
|
# "sidekiq"; `cronwatch` in the job is the run's context, for log and
|
|
31
31
|
# metric. A perform that raises is recorded as failed and then raises as
|
|
32
32
|
# before, so Sidekiq's retries, death handlers, and error handlers see it
|
|
33
|
-
# unchanged.
|
|
33
|
+
# unchanged. A perform stopped by Sidekiq::Shutdown is recorded but not
|
|
34
|
+
# judged: Sidekiq runs the job again after the restart, so a deploy opens
|
|
35
|
+
# no failed alert.
|
|
34
36
|
#
|
|
35
37
|
# class NightlyReportJob
|
|
36
38
|
# include Sidekiq::Job
|
data/lib/cronwatch/types.rb
CHANGED
|
@@ -386,7 +386,7 @@ module Cronwatch
|
|
|
386
386
|
# when there are none. An open condition this version does not know stays
|
|
387
387
|
# in `open` the same way, under its own name.
|
|
388
388
|
JobState = Struct.new(:job, :open, :consecutive_failures, :silenced_until, :last_alert_at, :pending_recovery,
|
|
389
|
-
:undelivered, :version, :sending, :under_floor, :extra, keyword_init: true) do
|
|
389
|
+
:undelivered, :version, :sending, :under_floor, :pending_runs, :extra, keyword_init: true) do
|
|
390
390
|
include Serializable
|
|
391
391
|
|
|
392
392
|
def self.from_h(hash)
|
|
@@ -398,6 +398,7 @@ module Cronwatch
|
|
|
398
398
|
undelivered = Naming.fetch(hash, "undelivered")
|
|
399
399
|
sending = Naming.fetch(hash, "sending")
|
|
400
400
|
under_floor = Naming.fetch(hash, "underFloor")
|
|
401
|
+
pending_runs = Naming.fetch(hash, "pendingRuns")
|
|
401
402
|
extra = hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v unless JobState::KNOWN.include?(k.to_s) }
|
|
402
403
|
new(
|
|
403
404
|
extra: extra.empty? ? nil : extra,
|
|
@@ -411,6 +412,7 @@ module Cronwatch
|
|
|
411
412
|
version: Naming.fetch(hash, "version"),
|
|
412
413
|
sending: sending.is_a?(Array) ? sending.map { |entry| JobState.sending_entry(entry) } : sending,
|
|
413
414
|
under_floor: under_floor,
|
|
415
|
+
pending_runs: pending_runs,
|
|
414
416
|
)
|
|
415
417
|
end
|
|
416
418
|
|
|
@@ -436,8 +438,8 @@ module Cronwatch
|
|
|
436
438
|
|
|
437
439
|
# pendingRecovery, undelivered, and version are left out when unset, as in
|
|
438
440
|
# state written before they existed. The version comes after them, where
|
|
439
|
-
# the SDK's spread of a normalized state puts it, then `sending
|
|
440
|
-
# `underFloor`, each only while it holds an entry.
|
|
441
|
+
# the SDK's spread of a normalized state puts it, then `sending`,
|
|
442
|
+
# `underFloor`, and `pendingRuns`, each only while it holds an entry.
|
|
441
443
|
def to_h
|
|
442
444
|
out = {
|
|
443
445
|
"job" => job, "open" => open.is_a?(Hash) ? open.transform_keys(&:to_s) : (open || {}),
|
|
@@ -457,6 +459,7 @@ module Cronwatch
|
|
|
457
459
|
end
|
|
458
460
|
end
|
|
459
461
|
out["underFloor"] = under_floor.dup if under_floor.is_a?(Array) && !under_floor.empty?
|
|
462
|
+
out["pendingRuns"] = pending_runs.dup if pending_runs.is_a?(Array) && !pending_runs.empty?
|
|
460
463
|
extra&.each { |key, value| out[key] = value unless out.key?(key) || JobState::KNOWN.include?(key) }
|
|
461
464
|
out
|
|
462
465
|
end
|
|
@@ -464,7 +467,8 @@ module Cronwatch
|
|
|
464
467
|
|
|
465
468
|
# The fields this version reads from a stored state; the rest are kept in `extra`.
|
|
466
469
|
# @api private
|
|
467
|
-
JobState::KNOWN = %w[job open consecutiveFailures silencedUntil lastAlertAt pendingRecovery undelivered version sending underFloor
|
|
470
|
+
JobState::KNOWN = %w[job open consecutiveFailures silencedUntil lastAlertAt pendingRecovery undelivered version sending underFloor
|
|
471
|
+
pendingRuns].freeze
|
|
468
472
|
|
|
469
473
|
JobStats = Struct.new(:runs, :ok_rate, :p50_ms, :p95_ms, keyword_init: true) do
|
|
470
474
|
include Serializable
|
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 "stringio"
|
|
6
7
|
require "uri"
|
|
7
8
|
|
|
8
9
|
module Cronwatch
|
|
@@ -24,11 +25,12 @@ module Cronwatch
|
|
|
24
25
|
# token: required to reach anything. Send it as `Authorization: Bearer <token>`,
|
|
25
26
|
# or open the dashboard once with `?token=<token>` (or sign in with the
|
|
26
27
|
# form, which posts it to <base>/signin) and a cookie is set.
|
|
27
|
-
# Defaults to ENV["CRONWATCH_TOKEN"]
|
|
28
|
+
# Defaults to ENV["CRONWATCH_TOKEN"]. It is trimmed of the whitespace
|
|
29
|
+
# around it; an empty string or one of only
|
|
28
30
|
# whitespace, given here or in the variable, counts as unset, and
|
|
29
31
|
# anything but a String or nil (false, a number, a Symbol) raises TypeError.
|
|
30
|
-
# With no token while Cronwatch::Environment is development
|
|
31
|
-
#
|
|
32
|
+
# With no token while Cronwatch::Environment is development
|
|
33
|
+
# (Environment.stated_development?: under Puma, Unicorn,
|
|
32
34
|
# Thin, or rackup, which set RACK_ENV=development by default,
|
|
33
35
|
# RACK_ENV alone does not count), the app makes a random one and
|
|
34
36
|
# prints a sign-in link to stdout on its first request; with no
|
|
@@ -39,18 +41,24 @@ module Cronwatch
|
|
|
39
41
|
# mount point (SCRIPT_NAME), which is right under Rails' `mount`.
|
|
40
42
|
# origin: the public origin the dashboard is served from, such as
|
|
41
43
|
# "https://app.example.com". By default the request's own origin
|
|
42
|
-
# is used
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# sign-in cookie's Secure flag, the Referer the redirect back after
|
|
48
|
-
# a form follows, and the development sign-in line. Read as
|
|
44
|
+
# is used: rack.url_scheme and the Host header, or the forwarded
|
|
45
|
+
# ones under trust_proxy. Set this to pin it. Used in place of the
|
|
46
|
+
# request's origin for the cross-site check on writes, the sign-in
|
|
47
|
+
# cookie's Secure flag, the Referer the redirect back after a form
|
|
48
|
+
# follows, and the development sign-in line. Read as
|
|
49
49
|
# `new URL(value).origin` reads it (Web::Origin) and normalised to
|
|
50
50
|
# scheme://host[:port]; anything that is not an absolute http or
|
|
51
51
|
# https URL, or has a port outside 1 to 65535, raises
|
|
52
|
-
# ArgumentError here. "" counts as unset.
|
|
53
|
-
#
|
|
52
|
+
# ArgumentError here. "" counts as unset. Takes precedence over
|
|
53
|
+
# trust_proxy.
|
|
54
|
+
# trust_proxy: take the public origin from X-Forwarded-Proto and
|
|
55
|
+
# X-Forwarded-Host (the first value of each, falling back to the
|
|
56
|
+
# request's own scheme or host for whichever is missing) when a
|
|
57
|
+
# request carries either, as the SDK's trustProxy does. Only for
|
|
58
|
+
# an app whose proxy sets or overwrites both headers: a client can
|
|
59
|
+
# send them too. Defaults to true when Rails is loaded, since Rails
|
|
60
|
+
# itself trusts them (ActionDispatch::Request#base_url), and to
|
|
61
|
+
# false otherwise, as in the SDK.
|
|
54
62
|
class Web
|
|
55
63
|
# Tells "token not given" (read CRONWATCH_TOKEN) from "token: nil" (open on purpose).
|
|
56
64
|
UNSET = Object.new.freeze
|
|
@@ -103,9 +111,10 @@ module Cronwatch
|
|
|
103
111
|
app
|
|
104
112
|
end
|
|
105
113
|
|
|
106
|
-
def initialize(client = nil, token: UNSET, base_path: nil, origin: nil)
|
|
114
|
+
def initialize(client = nil, token: UNSET, base_path: nil, origin: nil, trust_proxy: UNSET)
|
|
107
115
|
@client = client
|
|
108
116
|
@origin = Web.configured_origin(origin)
|
|
117
|
+
@trust_proxy = trust_proxy.equal?(UNSET) ? Web.rails? : trust_proxy == true
|
|
109
118
|
@opted_out = token.nil?
|
|
110
119
|
# A blank token, given or read, counts as unset; one given that is not a String raises.
|
|
111
120
|
given = token.equal?(UNSET) ? nil : Environment.secret_option(token, "routes: token")
|
|
@@ -117,10 +126,26 @@ module Cronwatch
|
|
|
117
126
|
# the server log.
|
|
118
127
|
@generated = @token.nil? && !@opted_out && Environment.stated_development?
|
|
119
128
|
@token = Web.development_token if @generated
|
|
129
|
+
Web.warn_short_token(@token) unless @generated
|
|
120
130
|
@announced = false
|
|
121
131
|
@announce_lock = Mutex.new
|
|
122
132
|
end
|
|
123
133
|
|
|
134
|
+
# Under this many characters a token could be guessed one request at a
|
|
135
|
+
# time, since nothing slows down wrong guesses.
|
|
136
|
+
SHORT_TOKEN = 32
|
|
137
|
+
private_constant :SHORT_TOKEN
|
|
138
|
+
|
|
139
|
+
# Warns, once per app, when the dashboard's token is short enough to guess.
|
|
140
|
+
#
|
|
141
|
+
# @api private
|
|
142
|
+
def self.warn_short_token(token)
|
|
143
|
+
return if token.nil? || token.length >= SHORT_TOKEN
|
|
144
|
+
|
|
145
|
+
warn "[cronwatch] the dashboard token is #{token.length} characters; use #{SHORT_TOKEN} or more random characters, " \
|
|
146
|
+
"such as the output of `ruby -rsecurerandom -e 'puts SecureRandom.urlsafe_base64(32)'`"
|
|
147
|
+
end
|
|
148
|
+
|
|
124
149
|
# A token for one app in development, when none is configured: 32 random
|
|
125
150
|
# bytes, base64url (43 characters).
|
|
126
151
|
def self.development_token
|
|
@@ -168,6 +193,11 @@ module Cronwatch
|
|
|
168
193
|
Origin.parse(value)
|
|
169
194
|
end
|
|
170
195
|
|
|
196
|
+
# Whether the app runs under Rails, which trusts forwarded headers by default.
|
|
197
|
+
def self.rails?
|
|
198
|
+
defined?(::Rails::Railtie) ? true : false
|
|
199
|
+
end
|
|
200
|
+
|
|
171
201
|
# The client given, or Cronwatch.client when none was.
|
|
172
202
|
def client
|
|
173
203
|
@client || Cronwatch.client
|
|
@@ -230,7 +260,9 @@ module Cronwatch
|
|
|
230
260
|
# The sign-in form posts the token here, in the body, so it stays out of
|
|
231
261
|
# the URL and every access log. Cross-site posts were refused above.
|
|
232
262
|
if @token && method == "POST" && path == "/signin"
|
|
233
|
-
|
|
263
|
+
body = read_body(request)
|
|
264
|
+
# A body too large is no token at all.
|
|
265
|
+
sent = body.equal?(TOO_LARGE) ? nil : body["token"]
|
|
234
266
|
return sign_in_page(base) if sent.nil? || !HTTP.constant_time_equal?(sent, @token)
|
|
235
267
|
|
|
236
268
|
return redirect(sign_in_return(request.header("referer"), public_origin(request), base),
|
|
@@ -260,7 +292,10 @@ module Cronwatch
|
|
|
260
292
|
# Move the token from the URL into a cookie, so it is not left in the
|
|
261
293
|
# browser's history. The request line that carried it may still be in
|
|
262
294
|
# an access log, which is why the sign-in form posts instead.
|
|
263
|
-
|
|
295
|
+
# One leading slash: "//host/..." would be a protocol-relative Location,
|
|
296
|
+
# off this site, and a browser reads a backslash there as a slash.
|
|
297
|
+
return redirect(request.pathname.sub(%r{\A[/\\]+}, "/") + request.search_without("token"),
|
|
298
|
+
"set-cookie" => sign_in_cookie(request, base))
|
|
264
299
|
end
|
|
265
300
|
end
|
|
266
301
|
|
|
@@ -301,8 +336,11 @@ module Cronwatch
|
|
|
301
336
|
return html(HTML.message_page("No such job", "#{name} is not in the store.", base), 404) unless cw.job_summary(name)
|
|
302
337
|
|
|
303
338
|
if action == "silence"
|
|
339
|
+
body = read_body(request)
|
|
340
|
+
return html(HTML.message_page("Not silenced", "The request was too large.", base), 413) if body.equal?(TOO_LARGE)
|
|
341
|
+
|
|
304
342
|
begin
|
|
305
|
-
duration = silence_duration(
|
|
343
|
+
duration = silence_duration(body["for"])
|
|
306
344
|
rescue ArgumentError => e
|
|
307
345
|
return html(HTML.message_page("Not silenced", e.message, base), 400)
|
|
308
346
|
end
|
|
@@ -346,6 +384,8 @@ module Cronwatch
|
|
|
346
384
|
|
|
347
385
|
if rest[2] == "silence"
|
|
348
386
|
body = read_body(request)
|
|
387
|
+
return api({ ok: false, error: "Request body too large" }, 413) if body.equal?(TOO_LARGE)
|
|
388
|
+
|
|
349
389
|
begin
|
|
350
390
|
duration = silence_duration(body.key?("for") ? body["for"] : request.query("for"))
|
|
351
391
|
rescue ArgumentError => e
|
|
@@ -399,7 +439,8 @@ module Cronwatch
|
|
|
399
439
|
end
|
|
400
440
|
return unless first
|
|
401
441
|
|
|
402
|
-
|
|
442
|
+
own = request_origin(request)
|
|
443
|
+
shown = @origin || (Web.loopback_origin?(own) ? own : nil)
|
|
403
444
|
$stdout.puts(Web.development_sign_in_line(shown, base, @token))
|
|
404
445
|
$stdout.flush
|
|
405
446
|
end
|
|
@@ -477,17 +518,26 @@ module Cronwatch
|
|
|
477
518
|
!site.nil? && site != "same-origin" && site != "none"
|
|
478
519
|
end
|
|
479
520
|
|
|
521
|
+
# A request body past Request::MAX_BODY, which read_body answers with.
|
|
522
|
+
TOO_LARGE = Object.new.freeze
|
|
523
|
+
private_constant :TOO_LARGE
|
|
524
|
+
|
|
480
525
|
# The form fields or JSON object of a request, each value as String(value)
|
|
481
|
-
# gives it in JavaScript
|
|
482
|
-
#
|
|
483
|
-
# order mark is dropped.
|
|
526
|
+
# gives it in JavaScript, or TOO_LARGE for a body past Request::MAX_BODY.
|
|
527
|
+
# The body is read as the SDK's request.json() reads it: bytes that are
|
|
528
|
+
# not UTF-8 become U+FFFD, and a leading byte order mark is dropped.
|
|
484
529
|
def read_body(request)
|
|
485
530
|
type = request.header("content-type") || ""
|
|
486
|
-
|
|
531
|
+
json = type.include?("application/json")
|
|
532
|
+
form = type.include?("application/x-www-form-urlencoded") || type.include?("multipart/form-data")
|
|
533
|
+
return {} unless json || form
|
|
534
|
+
return TOO_LARGE if request.too_large?
|
|
535
|
+
|
|
536
|
+
if json
|
|
487
537
|
data = JSON.parse(Output.utf8(request.body).delete_prefix(""))
|
|
488
538
|
return data.to_h { |k, v| [k.to_s, js_string(v)] } if data.is_a?(Hash)
|
|
489
539
|
return data.each_with_index.to_h { |v, i| [i.to_s, js_string(v)] } if data.is_a?(Array)
|
|
490
|
-
|
|
540
|
+
else
|
|
491
541
|
return request.form.transform_values { |v| form_value(v) }
|
|
492
542
|
end
|
|
493
543
|
{}
|
|
@@ -547,7 +597,12 @@ module Cronwatch
|
|
|
547
597
|
|
|
548
598
|
# The origin a browser sees: the `origin` option when set, otherwise the request's own.
|
|
549
599
|
def public_origin(request)
|
|
550
|
-
@origin || request
|
|
600
|
+
@origin || request_origin(request)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# The request's origin: the forwarded one under trust_proxy, else its own.
|
|
604
|
+
def request_origin(request)
|
|
605
|
+
@trust_proxy ? request.forwarded_origin : request.origin
|
|
551
606
|
end
|
|
552
607
|
|
|
553
608
|
def api(body, status = 200, headers = {})
|
|
@@ -607,11 +662,35 @@ module Cronwatch
|
|
|
607
662
|
@env[key]
|
|
608
663
|
end
|
|
609
664
|
|
|
610
|
-
# scheme://host[:port], the page's origin
|
|
611
|
-
#
|
|
612
|
-
#
|
|
665
|
+
# scheme://host[:port], the page's origin as the request URL gives it
|
|
666
|
+
# (rack.url_scheme and the Host header, never a forwarded header),
|
|
667
|
+
# normalised as a browser writes it in Origin and Referer.
|
|
613
668
|
def origin
|
|
614
|
-
@rack.
|
|
669
|
+
scheme = @env["rack.url_scheme"].to_s.downcase
|
|
670
|
+
scheme = "http" unless %w[http https].include?(scheme)
|
|
671
|
+
host = @env["HTTP_HOST"].to_s
|
|
672
|
+
host = [@env["SERVER_NAME"], @env["SERVER_PORT"]].compact.join(":") if host.empty?
|
|
673
|
+
Origin.bare("#{scheme}://#{host}") || "#{scheme}://#{host.downcase}"
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# With trust_proxy: the first X-Forwarded-Proto and X-Forwarded-Host,
|
|
677
|
+
# each falling back to the request's own, when either is present and
|
|
678
|
+
# well formed; otherwise the request's own origin. The SDK's forwardedOrigin.
|
|
679
|
+
def forwarded_origin
|
|
680
|
+
own = origin
|
|
681
|
+
proto = Request.first_value(header("x-forwarded-proto"))&.downcase
|
|
682
|
+
host = Request.first_value(header("x-forwarded-host"))
|
|
683
|
+
return own if proto.nil? && host.nil?
|
|
684
|
+
return own unless proto.nil? || %w[http https].include?(proto)
|
|
685
|
+
|
|
686
|
+
scheme, authority = own.split("://", 2)
|
|
687
|
+
Origin.bare("#{proto || scheme}://#{host || authority}") || own
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# The first of a header's comma-separated values, trimmed, or nil.
|
|
691
|
+
def self.first_value(value)
|
|
692
|
+
first = value&.split(",", 2)&.first&.strip
|
|
693
|
+
first.nil? || first.empty? ? nil : first
|
|
615
694
|
end
|
|
616
695
|
|
|
617
696
|
# URLSearchParams#get: the first value, or nil.
|
|
@@ -627,12 +706,24 @@ module Cronwatch
|
|
|
627
706
|
pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
|
|
628
707
|
end
|
|
629
708
|
|
|
709
|
+
# The most of a request body the routes read (1 MiB), as every port's.
|
|
710
|
+
MAX_BODY = 1 << 20
|
|
711
|
+
|
|
712
|
+
# Whether the body is past MAX_BODY: by its Content-Length when that
|
|
713
|
+
# says more, else by reading one byte past the cap at most, so a client
|
|
714
|
+
# cannot make the server hold a body of any size. The forms and JSON the
|
|
715
|
+
# routes take are a few bytes. What was read is left to be read again.
|
|
716
|
+
def too_large?
|
|
717
|
+
@too_large = measure_too_large if @too_large.nil?
|
|
718
|
+
@too_large
|
|
719
|
+
end
|
|
720
|
+
|
|
630
721
|
def body
|
|
631
722
|
input = @env["rack.input"]
|
|
632
723
|
return "" unless input
|
|
633
724
|
|
|
634
725
|
input.rewind if input.respond_to?(:rewind)
|
|
635
|
-
text = input.read.to_s
|
|
726
|
+
text = input.read(MAX_BODY + 1).to_s
|
|
636
727
|
input.rewind if input.respond_to?(:rewind)
|
|
637
728
|
text.force_encoding(Encoding::UTF_8)
|
|
638
729
|
end
|
|
@@ -645,6 +736,30 @@ module Cronwatch
|
|
|
645
736
|
fields.each_with_object({}) { |(k, v), out| out[Output.utf8(k)] = v }
|
|
646
737
|
end
|
|
647
738
|
|
|
739
|
+
private
|
|
740
|
+
|
|
741
|
+
def measure_too_large
|
|
742
|
+
length = @env["CONTENT_LENGTH"].to_s
|
|
743
|
+
return true if length.match?(/\A\d+\z/) && length.to_i > MAX_BODY
|
|
744
|
+
|
|
745
|
+
input = @env["rack.input"]
|
|
746
|
+
return false unless input
|
|
747
|
+
|
|
748
|
+
input.rewind if input.respond_to?(:rewind)
|
|
749
|
+
read = input.read(MAX_BODY + 1).to_s
|
|
750
|
+
return true if read.bytesize > MAX_BODY
|
|
751
|
+
|
|
752
|
+
# An input that cannot be rewound is handed on as what was read.
|
|
753
|
+
if input.respond_to?(:rewind)
|
|
754
|
+
input.rewind
|
|
755
|
+
else
|
|
756
|
+
@env["rack.input"] = StringIO.new(read)
|
|
757
|
+
end
|
|
758
|
+
false
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
public
|
|
762
|
+
|
|
648
763
|
# application/x-www-form-urlencoded parsing as URLSearchParams does it:
|
|
649
764
|
# "+" is a space, and a bad escape is kept as written rather than refused.
|
|
650
765
|
def self.parse_query(text)
|