cronwatch 0.12.4 → 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.
@@ -43,7 +43,7 @@ module Cronwatch
43
43
  # Run ids that start with this belong to the pg_cron source (Sources::PgCron).
44
44
  RESERVED_RUN_ID_PREFIX = "pgcron:"
45
45
  # The longest run id, in UTF-16 code units (JavaScript's string length):
46
- # what start, resume and record_run take, and every store holds.
46
+ # what start, resume, and record_run take, and every store holds.
47
47
  MAX_RUN_ID = 200
48
48
  private_constant :CHANNEL_TIMEOUT_MS, :DEFAULT_OPTIONS, :FORK_LOCK, :HISTORY_MAX, :HISTORY_PAGE, :MAX_RUN_ID,
49
49
  :PRUNE_INTERVAL_MS, :RESERVED_RUN_ID_PREFIX, :RETRY_BUDGET_MS, :SILENCE_OPTIONS, :STATE_ATTEMPTS,
@@ -77,19 +77,19 @@ module Cronwatch
77
77
 
78
78
  attr_reader :store, :alerts, :triage, :cron_secret, :retention_ms, :defaults, :sources
79
79
 
80
- # store: where jobs, runs and state live. Defaults to an in-memory store that forgets on restart.
80
+ # store: where jobs, runs, and state live. Defaults to an in-memory store that forgets on restart.
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
- # defaults: grace, timeout, timezone and failures_before_alert applied to every job unless it sets its own.
89
- # redact: applied to every run's output and error before it is stored, shown or sent to an alert
88
+ # defaults: grace, timeout, timezone, and failures_before_alert applied to every job unless it sets its own.
89
+ # redact: applied to every run's output and error before it is stored, shown, or sent to an alert
90
90
  # channel or triage. The default (Output.redact_secrets) blanks values that look like secrets
91
91
  # (password=..., Authorization headers, URL credentials, bearer tokens, JWTs, PEM private
92
- # keys, webhook URLs, AWS, GitHub, Slack, Stripe, Google and API key formats). Pass your own
92
+ # keys, webhook URLs, AWS, GitHub, Slack, Stripe, Google, and API key formats). Pass your own
93
93
  # callable, or false to keep output exactly as logged. A callable that raises or returns
94
94
  # something other than a String is reported to on_error ("redact") and the default is used.
95
95
  # now: the clock, a callable returning epoch milliseconds. Tests use this.
@@ -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
@@ -159,18 +162,36 @@ module Cronwatch
159
162
  def job(name, **options)
160
163
  name = name.to_s if name.is_a?(Symbol)
161
164
  unless name.is_a?(String) && NAME_RE.match?(name)
162
- raise ArgumentError, "job name \"#{name}\" must be 1 to 120 characters of letters, digits, \".\", \"_\", \":\" or \"-\""
165
+ raise ArgumentError, "job name \"#{name}\" must be 1 to 120 characters of letters, digits, \".\", \"_\", \":\", or \"-\""
163
166
  end
164
167
 
165
168
  definition = build_definition(name, options)
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)
@@ -618,7 +648,7 @@ module Cronwatch
618
648
  ticker
619
649
  end
620
650
 
621
- # Locks, the check in flight, the interval thread and the channel and
651
+ # Locks, the check in flight, the interval thread, and the channel and
622
652
  # triage threads belong to one process. A forked child (Puma, Unicorn,
623
653
  # Sidekiq) starts with fresh ones, so start and check work there.
624
654
  def reset_process_state
@@ -718,7 +748,7 @@ module Cronwatch
718
748
  expect = definition.expect
719
749
  return if expect.nil? || expect.is_a?(String) || expect.is_a?(Regexp) || expect.respond_to?(:call)
720
750
 
721
- raise ArgumentError, "job \"#{name}\": expect must be a string, a RegExp or a function"
751
+ raise ArgumentError, "job \"#{name}\": expect must be a string, a RegExp, or a function"
722
752
  end
723
753
 
724
754
  # String(value) as JavaScript writes it, for the messages above.
@@ -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}")
@@ -1338,7 +1427,7 @@ module Cronwatch
1338
1427
  Evaluate.unevaluable_summary(stored, recent, state, at)
1339
1428
  end
1340
1429
 
1341
- # Read, change and write one job's state, in turn with every other update to it.
1430
+ # Read, change, and write one job's state, in turn with every other update to it.
1342
1431
  def patch_state(name)
1343
1432
  ensure_ready
1344
1433
  state, = update_state(name) do |current|
@@ -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
 
@@ -1492,13 +1595,17 @@ module Cronwatch
1492
1595
  end
1493
1596
 
1494
1597
  # Sets the alert's triage to the diagnosis, or to nil (JSON null) when
1495
- # 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.
1598
+ # there is none (it raised, timed out, or answered nil or ""), so it is
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")]
@@ -34,7 +34,7 @@ module Cronwatch
34
34
  return value
35
35
  end
36
36
  unless value.is_a?(String)
37
- raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\""
37
+ raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\", or \"90s\""
38
38
  end
39
39
 
40
40
  if value.length > MAX_LENGTH
@@ -52,7 +52,7 @@ module Cronwatch
52
52
  consumed << match[0]
53
53
  end
54
54
  if consumed.gsub(JS::SPACES, "") != text.gsub(JS::SPACES, "")
55
- raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\""
55
+ raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\", or \"90s\""
56
56
  end
57
57
 
58
58
  JS.round(total)
@@ -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
@@ -104,10 +115,10 @@ module Cronwatch
104
115
  end
105
116
 
106
117
  # Whether development was named by the app rather than by its server,
107
- # for the dashboard's own token (Cronwatch::Web). Puma, Unicorn, Thin
118
+ # for the dashboard's own token (Cronwatch::Web). Puma, Unicorn, Thin,
108
119
  # and rackup set RACK_ENV to "development" when nothing names an
109
120
  # environment, so under one of them, in production too, RACK_ENV alone
110
- # reads "development". There it takes CRONWATCH_ENV, APP_ENV, Rails.env
121
+ # reads "development". There it takes CRONWATCH_ENV, APP_ENV, Rails.env,
111
122
  # or RAILS_ENV to say development; any other value of RACK_ENV that reads
112
123
  # as development ("test", "dev") still counts, as no server sets it.
113
124
  def stated_development?