cronwatch 0.12.5 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a99bb19743d769cd5e3c5c105c83410ad2882378e3d05707f9b607523a2372d4
4
- data.tar.gz: 895572e99a4849f4ab93f072289236d6a1e21c45fc5d4b57c11d449469a687d1
3
+ metadata.gz: 2550285307bd17b80e7aace7fe3ed098853b83258f1e9b9030d666739b415020
4
+ data.tar.gz: 4392543773b56b11d1be70af9b67f0077ead1fff1efd0477b4a33733daf7337b
5
5
  SHA512:
6
- metadata.gz: 0f93f594c2a75ff6b7f86aa30ae487f9ae9828e31c0fa3fb21e32a732b69232bd0b442f6d0be1fb2188f545e098d7f1d59179d3ed44627bdc46b259071384a2b
7
- data.tar.gz: c994fff472654f78cd23de0f3be5575cb784d0f0cb16e705ed76cd5bf4eb130ef5513dc47a94b48cbf1c4c123463a04e104ae7b4e70178eae3df93d42b73a46b
6
+ metadata.gz: 9d9eb6e74fd3c89fbe884c6c8d0801a2b82a47860fb00d88e181a5e91846d67cb1c072cbbbe8c16dfda467c6d09b7d382f71fee0072638130003552c6c6f0596
7
+ data.tar.gz: 1971c1a964d90823ce29c32d8661fa12ec19460557ff3650c842479cdf166556ca550ca24deab7aeaf613aa7c51ab510a5c76a8883353123ed56657849ea69af
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`, `test`, or `testing`, 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 `test`, `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 what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
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}: #{JS.head16(response.body.to_s, 200)}" unless response.ok?
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}: #{JS.head16(response.body.to_s, 200)}" unless response.ok?
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
- def call(alert)
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 }
@@ -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 only whitespace, given here or in the
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
- declared = @registry.synchronize { @definitions[name_or_id.to_s] }
188
- handle = options.any? || declared.nil? ? job(name_or_id, **options) : JobHandle.new(self, declared)
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
- ignored = record_finish(definition, run, recorded, finished_at)
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 = @registry.synchronize { @definitions[name] }
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, and in any other process that
500
- # declares it on its next run there, or at that process's next check or
501
- # dashboard read.
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
- return [] if !evaluate || (late && run.status != :ok)
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&.call(result))
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
- finish_run(Serialize.to_stored(definition), run, finished_at) if !late || run.status == :ok
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
- outbox(Evaluate.apply_silence(previous, Evaluate.on_run_finish(definition, run, previous, past, at), at), definition, at)
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. `state` is the job's state as this check left it: an alert that
1380
- # no longer describes it (Evaluate.stale_alert?) is dropped instead.
1381
- # Retries across a check share RETRY_BUDGET_MS of wall-clock time; once it
1382
- # is spent the rest stay queued for the next check.
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
- pending = state.undelivered || []
1385
- return [] if pending.empty? || Evaluate.silenced?(state, at) || @defer_delivery
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
- dropped = pending.select { |alert| Evaluate.stale_alert?(alert, state) }
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
- break if left <= 0
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, dropped, at)
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 is still going,
1497
- # alerts go out without one rather than start another beside it.
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
- raise TimeoutError, "skipped: an earlier triage timed out and is still running"
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", "test", and "testing" as "development". None set is neither,
9
- # which is the safe reading. The SDK reads NODE_ENV where this reads the
10
- # app's own.
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): nil when
63
- # the variable is unset, empty, or only whitespace, so a blank value counts
64
- # as not set and the routes fail closed. Any other value is used as it
65
- # is, untrimmed.
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
- value = ENV.fetch(variable, nil)
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 that is
72
- # empty or only whitespace counts as not given (nil here; the caller
73
- # tells that from nil the opt-out). Anything else (false, a number, a
74
- # Symbol) raises TypeError naming the option, so it never becomes a
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
- blank?(value) ? nil : value
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 (including test): Cronwatch::Web without a token serves only then.
108
+ # Development (not test): Cronwatch::Web without a token serves only then.
98
109
  def development?
99
110
  environment == "development"
100
111
  end
@@ -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 `under_floor`
87
- # are the exceptions: each is there only while it holds something.
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 its strings (absent when none). Unknown fields
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 nothing is sent.
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), alerts: [])
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
@@ -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 two secrets without stopping at the first differing character.
145
- def constant_time_equal?(a, b)
146
- return false unless a.bytesize == b.bytesize
147
-
148
- diff = 0
149
- a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y }
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
@@ -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
- outcome = client.execute(handle.definition, trigger) do |context|
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
@@ -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
@@ -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` and
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].freeze
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.12.5"
5
+ VERSION = "0.13.1"
6
6
  end
@@ -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"]; an empty string or one of only
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 or
31
- # test (Environment.stated_development?: under Puma, Unicorn,
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, as Rack reads it (Rack::Request#base_url, which follows
43
- # X-Forwarded-Proto and X-Forwarded-Host the way Rails does). Set
44
- # this to pin it, for an app behind a proxy that does not set
45
- # those headers or one that should not trust them. Used in place
46
- # of the request's origin for the cross-site check on writes, the
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. Behind more than one
53
- # proxy set it: Rack takes the last of several forwarded values.
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
- sent = read_body(request)["token"]
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
- return redirect(request.pathname + request.search_without("token"), "set-cookie" => sign_in_cookie(request, base))
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(read_body(request)["for"])
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
- shown = @origin || (Web.loopback_origin?(request.origin) ? request.origin : nil)
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. The body is read as the SDK's request.json()
482
- # reads it: bytes that are not UTF-8 become U+FFFD, and a leading byte
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
- if type.include?("application/json")
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
- elsif type.include?("application/x-www-form-urlencoded") || type.include?("multipart/form-data")
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.origin
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, lowercased as a browser
611
- # writes it in Origin and Referer (Rack's base_url keeps the case of
612
- # the Host or X-Forwarded-Host it read).
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.base_url.downcase
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)
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.12.5
4
+ version: 0.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips